Skip to content

Promote develop to main With the Hub-Hosted Workflow Tasks and the LF Default - #768

Merged
ptr727 merged 11 commits into
mainfrom
develop
Aug 16, 2026
Merged

Promote develop to main With the Hub-Hosted Workflow Tasks and the LF Default#768
ptr727 merged 11 commits into
mainfrom
develop

Conversation

@ptr727

@ptr727 ptr727 commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Promote develop to main, carrying the remaining stages of the hub-hosted reusable-workflow rollout and the changes that landed beside them:

The release that follows this promotion is the first tag carrying every hub task, so it is the pin the stage 2 to 5 adoptions and their catalog snippets use. It is also the first run of the hub's own publish-release.yml through build-release-task.yml with every target disabled, which is the live proof that github-release runs when its build needs are skipped.

Closes #729.
Refs #521 (hub half shipped, the merge-bot adoption sweep is what remains).

ptr727 added 11 commits August 15, 2026 19:08
## What

- `docs/reusable-workflows.md` gains a **Rollout** section replacing the
prose "Migration Phases": six stages (0 design and merge-bot task, 1
merge-bot adoption, 2 gates, 3 pure functions, 4 release chain and
Docker core, 5 type-specific), each with hub, release, and per-repo
adoption checkboxes. Items are ticked only with the evidence that closed
them (PR, commit, tag, run URL), and the section opens with how a
session resumes from it: read it first, take the first unchecked item in
an open stage, verify against the tree, work in a worktree, tick in the
same PR. Stage 0 is ticked with #744, #746, and release `2.0.338`, apart
from the two live-proof items that wait on the next Dependabot PRs.
- `catalog/snippets/workflows/merge-bot-pull-request.yml`: the caller
stub, now that `2.0.338` on `20616e0` carries `merge-bot-task.yml`. The
pin resolves under `repo_gate.py check_sha_pin`. The doc's stub carries
the same pin, and the catalog README gains the row.
- `TODO.md`: the cluster names the Rollout section as the state tracker
and drops the snippet entry it just shipped.

## Why

The plan lived as prose and as TODO entries, and nothing in git said
which stage was done, so a cold session had no first unchecked item to
take. Only git persists.

## Verification

prose_lint (CI rule list), actionlint, markdownlint, `repo_gate.py` (the
new pin resolved against GitHub), unit tests, audit self-test,
build_dist --check.
…753)

## What

Two `host-setup/` defects found by running the tooling on a live host,
each of which reports success while doing the wrong thing.

- **`host-setup/linux/install-skills.sh`** gains `refuse_sudo`, run
straight after `parse_args` so `--help` still answers and every action
stops. Under `sudo` the installer resolved `~` to root's home, so the
skills landed in `/root/.agents/skills`, and it looked `claude` up on
root's `secure_path`, so a CLI on the invoking user's own `PATH` read as
absent and the run reported the marketplace unregistered. Both at exit
0. The guard tests `SUDO_USER` alongside `EUID` rather than `EUID`
alone, because a host whose working account is root, a container or a
Proxmox node, carries no `SUDO_USER` and installs for root correctly.
`--report` is refused with the rest, since under `sudo` it reads root's
stamp and answers for a machine state nobody installed. The `--help`
text and the header comment now say the script takes no elevation, where
the two siblings in the same stand-up flow elevate per command
themselves. This is shape 1 from #750, kept to the shell script.
- **`spec/host-tools.json` and `host-setup/linux/install-tools.sh`** ask
the daemon for its version before reading the CLI's banner. The probe
list becomes `docker version --format '{{.Server.Version}}'` then
`docker --version`, and `docker_version()` does the same two readings in
the same order, so the gate and the installer never disagree about one
host. One pattern spans both, `^\s*(?:Docker version )?(\d+(?:\.\d+)*)`,
anchored because an unanchored optional prefix reads the first digits
anywhere in the output. The engine reading applies on every platform
rather than only inside WSL: on a native install the CLI and the engine
are one package, so the number does not change there, and
`spec/host-tools.json` has no way to express a WSL-conditional probe.
- **`docs/host-setup.md`** gains the engine-versus-client paragraph, and
the floor's `why` no longer claims the probe reads the engine while
reading the client. The **Present when** column still names the banner,
since presence is what it answers and a host with the daemon stopped
still has `docker` installed.

## Why

Both defects fail in the direction that reads as success. The sudo'd
skills install exits 0 having installed for the wrong user, and a later
`--report` as the actual user then calls the same tree uninstalled. The
docker floor is an engine number, and on a WSL host where the CLI on
`PATH` is packaged separately from Docker Desktop's engine the two are
different versions, so a current host failed a floor its engine cleared
comfortably.

## Tests

Five in `test_skills_install.py` covering the guard's four outcomes and
`--help` under `sudo`, and two in `test_host_gate.py` for the probe
order and for the pattern against each probe's real output.

The guard reads `$EUID`, which bash makes read-only, so a sudo'd run
cannot be faked by setting a variable. The cases reach a real EUID 0
through `unshare -r` and skip where a host restricts unprivileged user
namespaces, which is probed rather than assumed.

| run | result |
| --- | --- |
| root, `SUDO_USER` names a user | refused, exit 1, names that user |
| root, no `SUDO_USER` | installs, exit 0 |
| root, `SUDO_USER=root` | installs, exit 0 |
| ordinary user, `SUDO_USER` set | installs, exit 0 |

## Verification

675 unit tests, `spec/validate.py`, `scripts/repo_gate.py`,
`scripts/build_dist.py --check`, both `prose_lint.py` invocations, ruff
check and format, mypy, shellcheck and editorconfig-checker in their
pinned images. `scripts/host_gate.py` and `install-tools.sh --report`
were each run on the host from #751 and now agree on the engine version,
where the gate previously failed the floor. The guard's four outcomes
were exercised directly before the tests were written.

Fixes #750
Fixes #751
`scripts/tests/test_bootstrap.py` defined its ten checks as module-level
`test_*` functions with its own `check()`/`failures` collector and
`main()` runner, no `unittest.TestCase`. CI's only invocation,
`validate-task.yml`'s `unittest discover -s scripts/tests`, loads
`TestCase` subclasses from each module it imports; a bare `test_*`
function is not one, so none of the loader-invariant or
spec-to-installer coverage checks ran.

## What changed

Converted the file to `unittest.TestCase`, matching the other six files
under `scripts/tests/`:

- `TestLoaderInvariant` — the two loaders each read exactly one path
into the fetched tree, and need no Python.
- `TestSpecCoverage` — every tool `spec/host-tools.json` requires is one
the platform installers can provide, and every floored tool carries a
total remedy mapping.
- `TestScriptPresence` — every script a loader hands control to is
present and, on Linux, tracked executable; the Windows scripts and
`bootstrap.ps1` carry no shebang.
- `TestHarness` — floors the collected test count, matching the sibling
files' pattern.

Dropped the `check()`/`failures` global collector for `self.assert*` and
`self.subTest`, so a loop over multiple items (tools, platforms,
forbidden paths) reports each failing item as its own subtest instead of
one lumped list. Added the shebang and executable bit the other six
sibling files carry.

## Verification

- `python3 scripts/tests/test_bootstrap.py` — 11 tests, OK.
- `python3 -m unittest discover -s scripts/tests` — 686 tests, OK
(previously ran none of this file's checks; confirmed by grepping the
`-v` output for `bootstrap` before this change).
- `uvx coverage@latest run --source=scripts,spec,host-setup -m unittest
discover -s scripts/tests` — the exact CI invocation — 686 tests, OK.
- `uvx ruff@latest check .`, `uvx ruff@latest format --check .`, `uvx
mypy@latest` — clean.
- `python3 scripts/prose_lint.py`, `python3 scripts/repo_gate.py` —
clean.
- `docker run ... mstruebing/editorconfig-checker:latest` — clean on the
changed file (only gitignored local tool caches flagged, not part of the
change).
- Fault injection: appended a forbidden `$TREE/spec/...` read to a
scratch copy of `host-setup/bootstrap.sh` and reran —
`TestLoaderInvariant` failed with the expected message, confirming the
converted checks actually catch a violation rather than passing
vacuously. Reverted before committing.

Fixes #754
## What

Switches the fleet's line-ending default from CRLF to LF. Only
`*.bat`/`*.cmd` are pinned CRLF
going forward, the one type Windows itself requires it for.
`.gitattributes` is unchanged per
the maintainer's direction, its execution-sensitive LF pins stay as
git-level enforcement,
now redundant with the new `[*]` default but retained.

## Why

Windows GUI tooling (VS Code, Visual Studio, Notepad, WordPad) all read
and write LF cleanly,
and the fleet's own tooling (Dependabot, `uv`, most editors) already
writes LF by default, so
the CRLF default had become mostly a source of churn against tooling
that keeps normalizing
back to LF, not a real Windows-compatibility requirement. `.ps1` was
verified to work fine on
LF too.

## Changes

- `.editorconfig`: `[*] end_of_line` flips to `lf`, `*.bat`/`*.cmd`
pinned `crlf`, the
now-redundant per-type LF overrides (shell, Dockerfiles, workflow YAML,
`uv.lock`,
shebang-executed `.py` by path, the husky hook) are dropped since the
default already
  covers them.
- One-time renormalization of every tracked CRLF file in the hub to LF
(160 files, verified
  content-identical via `git diff --ignore-cr-at-eol`).
- Updated every doc/spec/script that described or depended on the old
CRLF-default assumption:
`GOVERNANCE.md`, `README.md`, `OPERATIONS.md`, `STANDUP.md`,
`docs/host-setup.md`, `TODO.md`
(drops the now-superseded "Default `.py` to LF" cluster, since a global
LF default subsumes
it), `spec/project-types.json`, `spec/validate.py`,
`registry/repos.schema.json`, several
Skills (`comment-and-doc-style` most heavily, plus
`operational-vs-release-workflow`,
`python-codestyle`, `skill-lifecycle`, `git-commit-conventions`), two
catalog snippets, and
the CRLF report-writers in `spec/workflow_reuse.py` /
`spec/fidelity_honesty.py`.
- `scripts/build_dist.py`'s plugin-manifest and digest-stamp writers now
emit LF explicitly
instead of CRLF; `scripts/tests/test_build_dist.py` updated to match,
with a note on why the
old test's CRLF-on-Linux discrimination trick doesn't have an
LF-direction equivalent.
- `.claude-plugin/fleet-skills/` regenerated from the updated
`.agents/skills/` source.
- Adds `docs/eol-lf-rollout.md`, a hub-only checklist tracking the same
conversion across the
other 20 fleet repos (ProjectTemplate and Vantage-Config already checked
off, the latter as
not-applicable since it stays CRLF for its Windows-native consuming
app).

## Verification

`editorconfig-checker`, `markdownlint-cli2`, `cspell`, `actionlint`,
`repo_gate.py`,
`prose_lint.py --diff`, `spec/validate.py`, `spec/audit.py --selftest`,
`spec/workflow_reuse.py --selftest`, and the `scripts/tests/` +
`host-setup/agent-safety/test_install.py` suites all pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…ts (#752)

## What

- `docs/reusable-workflows.md` Rollout: stage 1 names PhotoCleaner as
the pilot, adopted on `develop` in ptr727/PhotoCleaner#53 at `a3158ce`
(audit clean on that branch), ticking on promotion to its ground-truth
branch per the tracker's own rule, with two proof items (the first
`pull_request_target` run resolving the pin and merging a Dependabot PR,
and Dependabot bumping the pin). HomeAutomation-Config moves to the
operational slot. Stages 2 and 4 put PhotoCleaner first as well.
- `TODO.md`: the cluster's pilot line and the sweep entry's outstanding
count follow.
- `reports/workflow-reuse.md` regenerated at this hub state
(PhotoCleaner still counts as a copy there because its ground truth is
`main`).

## Why

The maintainer chose PhotoCleaner as the first repo to test with:
release model, Dependabot, C#, executable and Docker targets, recently
converged, so what it shows is the mechanism rather than the repo. The
tracker is the record a cold session resumes from, so the choice and its
state land in git.
Adds install-tools.sh --sudo-timestamp, which writes a sudoers drop-in scoped to the invoking user so one "sudo -v" covers every terminal that user has open. bootstrap.sh runs it first in a stand-up and carries it as --sudo.

Ubuntu ships sudo-rs as its default sudo from 25.10, and it carries no timestamp_type at all, so the action asks each installed implementation's own visudo whether it parses the drop-in and offers to point the sudo alternative at one that does.

The write proves the sudoers set parses before adding to it, proves any implementation it would switch to parses it too, stages under a name sudo skips, and renames atomically.
Stage 3 of the hub-hosted reusable-workflow rollout
(`docs/reusable-workflows.md`
"Rollout"). Hosts `get-version-task.yml` and `publish-plan-task.yml` in
the hub's
`.github/workflows/`, moved out of `catalog/snippets/workflows/`.

## What moved

- `get-version-task.yml`: the catalog canonical, with action pins
updated to the
hub's current `actions/checkout@...v7.0.1` and
`actions/setup-dotnet@...v6.0.0`
(the catalog copy had fallen behind what the hub's own
`publish-release.yml`
already runs). Read against the 8 downstream copies (ESPHome-NonRoot,
NxWitness,
PhotoCleaner, PlexCleaner, VSCode-Server-DotNetCore, KiCadLibrary,
aiopurpleair,
homeassistant-purpleair): 5 are byte-identical to the canonical,
KiCadLibrary only
documents the same design, and aiopurpleair and homeassistant-purpleair
each add a
`Prerelease` output derived from `SemVer2` with identical logic
(`SemVer2` contains
a `-` segment). That derivation is a real, duplicated improvement, so it
is hosted
as a sixth output on the task rather than left as per-repo logic.
Homeassistant's
`Tag` output is not carried forward, since it is a bare copy of
`SemVer2` with no
  derivation of its own, and a caller can read `SemVer2` directly.
- `publish-plan-task.yml`: the catalog canonical, unchanged in behavior.
All 3
downstream copies (ESPHome-NonRoot, NxWitness, Utilities) are a strict
subset of
it, missing the `-E` in `set -Eeuo pipefail` and the `::warning::`
branch for an
unrecognized actor pushing to `main` (WORKFLOW.md D8.4), so nothing from
the
  copies is folded in.

Neither task nests the other, and neither is called from a hub `./` path
in this
PR: `build-release-task.yml` (stage 4, a sibling agent's PR) inlines the
get-version and validate-release jobs rather than nesting a sibling hub
task, per
the design's no-`./`-nesting rule. A downstream `publish-release.yml`
stub that
reads version outputs directly, without carrying the whole release
orchestrator,
calls `get-version-task.yml` by pin instead. Both shapes are documented
in the new
"Adopting the Pure Functions" section of `docs/reusable-workflows.md`,
with
placeholder pins (`@<hub-main-commit-sha> # <release-tag>`) since no
release has
carried these tasks yet.

## Manifest and audit

Per the brief, no `interface` contract is added to `spec/files.json` for
either
task: they are hub-only files with no caller-stub filename convention of
their
own (a caller reaches them as a job inside its own `publish-release.yml`
or
`build-release-task.yml`, not a standalone top-level workflow), so there
is no
downstream stub shape to check today. `spec/audit.py`'s
`check_interface`
selftest set is unchanged for the same reason.

Confirmed the manifest-gap mechanics will do what is expected once this
merges:
`spec/audit.py`'s `hub_only_paths` is the hub's own `git ls-files`
output minus
`spec/files.json`'s baseline paths, so
`.github/workflows/get-version-task.yml`
and `.github/workflows/publish-plan-task.yml` join that set the moment
they land
here undeclared in the manifest. `spec/fidelity_honesty.py`'s
`manifest_gap_pass`
then walks every cataloged repo's ground-truth tree and reports one
whose tree
still contains either path as a gap naming that repo, which is exactly
how a
downstream copy left over after this PR would surface once the
fleet-wide report
runs. `spec/divergences.json` gains two `retire` gap dispositions, one
per path,
naming the current carriers so the burn-down report reads as tracked
rather than
UNTRIAGED. `python3 spec/validate.py` accepts them
(`spec/divergences.schema.json`
has no `repos` field on a gap entry, so carriers are named in the
`reason` text
instead, matching the ledger's existing prose style).

`scripts/prose_lint.py`'s `HUB_HOSTED` literal (which exempts a
hub-hosted path
from the dead-path check once a downstream repo retires its own copy)
gained both
new paths, since `scripts/tests/test_prose_lint.py`'s
`test_the_hub_hosted_set_matches_the_ledger` asserts that set equals
every
`retire` gap path in the ledger.

## Not run live

Per the brief, the live fleet report
(`python3 spec/fidelity_honesty.py --report`) was not run in this PR,
since it
reads every cataloged repo over the network and is not guaranteed to
finish
quickly. The two new ledger entries were checked structurally instead:
`spec/
validate.py` accepts the schema, and `spec/audit.py --selftest`'s
`hub-only set excludes every declared path` case (184 hub-hosted paths
now, up
from 182) confirms the two new paths are read into `hub_only_paths` as
expected.
`reports/workflow-reuse.md` is not regenerated here either, per the
Rollout
section's rule that regeneration happens in the PR that ticks a stage's
last
adoption, not the hub PR.

## Tracker

- `docs/reusable-workflows.md` "Rollout" Stage 3: ticked the hub pull
request
item with this PR, expanded the single adoption line into one checkbox
per
carrier (9 repos, with ESPHome-NonRoot and NxWitness carrying both
files), and
added a catalog-snippet follow-up item concluding that no snippet is
warranted
  today since a caller of either task is a job inside a larger stub.
- `TODO.md` "Hub-Hosted Reusable Workflows" pure-functions bullet: added
Settled
  lines recording the fleet reading above.

## Gates run

`spec/validate.py`, `unittest discover -s scripts/tests` (686 tests),
`spec/
audit.py --selftest`, `spec/workflow_reuse.py --selftest`,
`scripts/repo_gate.py`,
`scripts/build_dist.py --check`, `scripts/prose_lint.py` with the named
checks,
`ruff check`/`ruff format --check`, `mypy`, and the Docker `actionlint`,
`markdownlint-cli2`, and `editorconfig-checker` invocations (the last
flags only
`.git`, `.mypy_cache`, `.ruff_cache`). All pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Hosts the fleet's validation gate once, as `validate-task.yml`, per
`docs/reusable-workflows.md` "Stage 2: The Gates".

## What ships

- `.github/workflows/validate-task.yml` becomes a `workflow_call` task
with three jobs:
- `lint`: the fleet doc-lint block (markdownlint, cspell, actionlint,
editorconfig-checker, shellcheck and PSScriptAnalyzer gated on file
presence), language lint by tree detection (a `*.csproj` runs the C#
checks, a `pyproject.toml` runs ruff/mypy/pyright), the prose gate
scoped to a pull request's diff, and the repo gate against the caller's
root.
- `unit-test`: a generic `dotnet test` or `uv run pytest` with Codecov
upload, skipped cleanly where the caller carries no test project.
- `validate`: resolves a `validate` hook (`.github/actions/validate` in
the caller, else the new no-op `.github/actions/validate-default` in the
hub) for a repo's own domain checks (an ESPHome compile, a Hugo build, a
KiCad ERC, a codegen-drift check, PowerShell tests).
- The hub is its own first consumer: `test-pull-request.yml` and
`publish-release.yml` keep calling
`./.github/workflows/validate-task.yml` unchanged, and the hub's own
domain steps (the registry/spec check, the coverage-wrapped self-tests,
the fleet-skills freshness check, and the unclassified-character report)
move into a new hub-local hook, `.github/actions/validate/action.yml`.
This makes the hub exercise the hook's **override** path on every hub
pull request, and a repo carrying no hook of its own exercise the
**default**.
- There is no `test-pull-request-task.yml`: the ruleset-bound aggregator
has to stay in the caller stub (a called job's check context would read
`<caller job> / <callee job>` and break the ruleset binding), and a
second hub task wrapping the one line a caller stub already writes to
call `validate-task.yml` hosts nothing generic. The two downstream stub
shapes (an operational no-build repo, and a release repo with a smoke
build) are documented instead, in `docs/reusable-workflows.md` "Adopting
the Gates", closing #585 by construction.
- Closes #729: the fleet's `uvx <tool>@latest` invocations are pinned or
floated in this one place, Dependabot tracking the action SHA that
installs `uv` rather than a `uvx` version nothing tracks.
- `spec/files.json`: `validate-task.yml` stops being a manifest `intent`
entry, since the hub hosts it rather than every repo carrying a copy.
`test-pull-request.yml`'s `interface` contract gains
`requireTokensInJob: {"validate": ["validate-task.yml"]}`.
- `spec/divergences.json`: a new `retire` gap disposition names the 13
current carriers (PhotoCleaner, PlexCleaner, LanguageTags, Utilities,
MediaTools, AudioCleaner, aiopurpleair, Financial-Modeling, Blog,
ESPHome-NonRoot, NxWitness, VSCode-Server-DotNetCore,
HomeAutomation-Config).
- `spec/audit.py`: new `_selftest` cases for the `requireTokensInJob`
contract, including a stub still carrying an inline lint job.
- `scripts/prose_lint.py`: `HUB_HOSTED` gains the retired path, so a
repo naming the hub's copy in its own prose is not read as a dead path,
with a hub self-test asserting the literal matches the ledger.
- `docs/reusable-workflows.md`: the Hook Catalog drops the
`test-pull-request-task.yml` row, a new "Adopting the Gates" section
gives both downstream stub shapes, and the Rollout tracker's Stage 2
item is reworded to match what shipped.
- `TODO.md`: the gates entry's Checked/Settled lines updated to what
shipped, both open questions resolved, and #585/#729 noted settled by
design.
- `GOVERNANCE.md`: one added sentence on "Running the Linters Locally"
naming the doc-lint block as the hub's validate task.

## Design choices where the brief left room

- **`job.workflow_sha`, not `github.job_workflow_sha`.** The brief's own
text (and the hub's `docs/reusable-workflows.md`) named
`github.job_workflow_sha`, but that property does not exist. The real
GitHub Actions property for "the commit SHA of the reusable workflow
file that defines the current job" is `job.workflow_sha`. Fixed at both
call sites in the new task and in `docs/reusable-workflows.md`'s
"Layers" section, verified live: `github.job_workflow_sha` fails
actionlint (unknown property on the `github` context) and
`job.workflow_sha` passes clean.
- **actionlint doesn't know `job.workflow_sha` yet either** (its bundled
context schema for the `job` context lists only `check_run_id`,
`container`, `services`, `status`), even though it is a real,
GitHub-documented property. Added a scoped `.github/actionlint.yaml`
`ignore` rule for the one file that reads it, rather than dropping the
checkout-at-pin design.
- **`unit-test`'s job-level `if:` cannot use `hashFiles`** (GitHub
Actions only evaluates it in a step context). Moved the tree-detection
guard onto every step instead; a caller with no test project runs the
job with every step skipped and reports success, which is the clean skip
the design calls for.
- **Hook override/default assignment.** The brief's design-decision text
says the hub carrying its own `validate` hook makes the hub exercise the
*override* path and a hookless repo exercise the *default*. A later
instruction's parenthetical read the opposite way round ("default path
on a hub PR run, override path on the pilot"). I followed the explicit
design-decision statement, since it states the actual mechanism and the
parenthetical does not, and worded the tracker's proof item to match
what the code does.

## Gates run

`spec/validate.py`, `python3 -m unittest discover -s scripts/tests`,
`spec/audit.py --selftest`, `spec/workflow_reuse.py --selftest`,
`scripts/repo_gate.py`, `scripts/build_dist.py --check`,
`scripts/prose_lint.py`
(charset/semicolon/dash/dupword/spelling/comment-wrap/comment-case/home-path/dead-path),
`uvx ruff@latest check .`, `uvx ruff@latest format --check .`, `uvx
mypy@latest`, and the Docker linters (actionlint, markdownlint-cli2,
editorconfig-checker) all pass clean.

## Not proven here (proof items left in the tracker)

- The hook fallback (override on a hub pull request run, default on a
hookless repo) is a claim, not yet a live run URL, since this PR has not
run in CI.
- Catalog snippets for both stub shapes wait for the release that first
carries this task, per the pin-gate rule.
- No downstream repo is touched. Adoption (including
HomeAutomation-Config and a C# pilot) is separate, later work per repo.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
`install-tools.sh --sudo-timestamp` already detected another sudoers
file setting the same timestamp option, but only ever warned about it. A
host that already carried a hand-authored drop-in (or one left by a
prior run of this script before this fix) ended up with two files
asserting the same thing, forever.

`configure_sudo_timestamp` now classifies each conflicting entry it
finds:

- **Scoped to this user, and the file sets nothing else**: delete it on
confirm, after the managed drop-in is proved in place.
- **Scoped to a different user, or unscoped**: report only, never
touched, since that changes something beyond what this run was asked to
change.
- **Scoped to this user but mixed with unrelated settings, or living in
`/etc/sudoers` itself**: die and ask for a manual `visudo` fix rather
than guess which lines are safe to drop from a shared file.

A decline leaves the host unchanged, matching the existing confirm-gate
pattern. The cleanup also runs when the managed file already has the
right content, so a host that already ran the old script and ended up
with both files gets the stray one cleaned up on its next run, not just
on a fresh install.

## Testing

- `bash -n`, `scripts/prose_lint.py`, `scripts/repo_gate.py`: clean.
- shellcheck (`koalaman/shellcheck:stable` via Docker, per
`GOVERNANCE.md`): clean.
- Real `--dry-run` against a live host carrying a genuine pre-existing
duplicate (`/etc/sudoers.d/pieter-global-timestamp`): correctly
classified it as a pure, this-user duplicate and staged its deletion.
- Isolated fixture harness (fake `SUDOERS_FILE` and search paths,
stubbed `visudo`/`install`, no real root writes), covering:
  - pure duplicate elsewhere -> deleted, managed file written
  - mixed-content elsewhere file -> dies, nothing written
  - already-correct managed file, no duplicate -> clean no-op
- already-correct managed file plus a stray pure duplicate -> stray
cleaned up, managed file left untouched
  - a different user's entry -> reported only, survives, write proceeds
## What

Stage 5 of the hub-hosted reusable-workflow rollout
(`docs/reusable-workflows.md`
"Rollout"). Hosts four more reusable tasks in the hub and retires one:

- `publish-docker-readme-task.yml`: the
`transform-run`/`transform-artifact` string
inputs become a `docker-readme-transform` hook, with a hub default that
publishes
`Docker/README.md` if present else `README.md` as-is. Explicit
`DOCKER_HUB_USERNAME`/
`DOCKER_HUB_ACCESS_TOKEN` secrets (the names `spec/secrets.json` already
declares).
A `branch` input keeps the readme push main-only inside the task itself.
- `check-upstream-version-task.yml`: the `resolver-command` string input
becomes a
required `resolve-upstream` hook. Adds an `auto-merge` boolean input
(default true);
false prefixes the bump PR's head so no merge-bot rule matches it,
routing the bump
to a human. ESPHome-NonRoot's second tracker,
`check-upstream-dependency.yml`, folds
  into this same task with `auto-merge: false` on adoption.
- `deploy-site-task.yml`: the three path-conventioned scripts
(`deploy/make-release.sh`, `deploy/prune-releases.sh`,
`checks/check-live-urls.sh`)
fold into one required `deploy` hook, invoked three times with a `mode`
input
(`build`, `prune`, `verify`), while the environment binding and the
atomic
upload-then-flip sequence stay hub-owned. `deploy-site.yml` stays a
per-repo caller,
  documented as a stub in `docs/reusable-workflows.md`.
- `run-codegen-pull-request-task.yml`: a required `codegen` hook for the
generation
step; the App-token, setup, format, and PR-opening machinery stay
hub-owned since
  both current carriers (LanguageTags, NxWitness) run it identically.
`NINJA_API_KEY` becomes an optional secret, since neither carrier maps
it today.
- `build-datebadge-task.yml` is retired rather than hosted: `TODO.md`
already tracks
deleting the retired `byob.yarr.is` badge from its remaining carrier
(KiCadLibrary).

Retires the corresponding catalog snippets and updates the catalog
README, adds
`retire` gap dispositions in `spec/divergences.json` naming carriers,
updates
`spec/files.json`'s `deploy-site.yml`/`deploy-site-task.yml` entries for
the new
hub-hosted contract (dropping `secrets: inherit`, requiring the mapped
`DEPLOY_SSH_PRIVATE_KEY` secret name instead), and adds
`check_interface` self-test
cases in `spec/audit.py` for every new stub contract. Fixes prose in
`WORKFLOW.md`
and the `operational-vs-release-workflow` skill that named the retired
snippets, the
old `secrets: inherit` shape, or the retired date-badge job. Adds
"Adopting the
Type-Specific Tasks" to `docs/reusable-workflows.md` with the caller
`uses:` lines
and hook names (pins as placeholders), ticks stage 5's hub PR item, and
lists
adoption checkboxes per carrier.

Rebased onto `develop` after #758 (the fleet's CRLF -> LF line-ending
flip), so every
new/edited file already follows the new LF default.

## Design choices where the brief left room

- `job.workflow_repository`/`job.workflow_sha` (not
`github.job_workflow_sha`, which
does not exist) resolve the hub checkout in
`publish-docker-readme-task.yml`'s
default-hook path. actionlint's context schema has not caught up to
these
  documented GitHub Actions `job` context properties yet, so a scoped
`.github/actionlint.yaml` ignore entry covers the false finding until it
does.
- The `deploy` hook is a single composite action taking a `mode` input
(`build`/`prune`/`verify`) rather than three separate hooks, so a site
repo owns
the whole per-repo half in one file. Blog's live copy already carries
more than
three clean scripts (its own `install-hugo` action, a git-mtime restore
step,
PANGOLIN tokens for its staging auth check), which argues for hook
flexibility
  over a path convention, not against it.
- The one secret that crosses the `deploy-site-task.yml`
reusable-workflow boundary,
`DEPLOY_SSH_PRIVATE_KEY`, is a GitHub Environment secret. The caller's
own `deploy`
job binds the same `environment:` the task binds, which is what lets
that secret
resolve at the call site for explicit mapping instead of `secrets:
inherit`
(forbidden here, and unusable cross-repository on a personal account
regardless).
- ESPHome-NonRoot's second tracker folds into
`check-upstream-version-task.yml` by
  treating its apt-package snapshot as a one-key name -> version object
(`{"docker_base_packages": "<sorted, comma-joined list>"}`). This loses
today's
bespoke "packages added/removed" title wording in favor of the shared
task's
  generic title, a documented tradeoff.

## Could not prove

- The `DEPLOY_SSH_PRIVATE_KEY` environment-secret handoff across a
cross-repository
`uses:` (the caller job's `environment:` binding resolving it for
explicit mapping)
is correct per GitHub's documented behavior, but only Blog's first live
deploy run
after adoption proves it. Tracked as a proof item in the rollout
tracker.
- `job.workflow_repository`/`job.workflow_sha` resolving to the hub at
the caller's
pinned commit is documented GitHub behavior, unverified by a live run in
this PR
(the hub has no downstream caller of its own docker-readme task to
trigger it).

## Conflicts with sibling work

None encountered. Stage 4 (`build-release-task.yml` etc.) has not merged
as of this
PR, so the `operational-vs-release-workflow` skill's "until that phase
ships the list
stays per repo" sentence is left as-is per the brief's instruction.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
## What

Hosts the fleet's release chain and Docker core in the hub, per
`docs/reusable-workflows.md`
"Stage 4: The Release Chain and the Docker Core".

- `.github/workflows/build-release-task.yml` (new hub task): inlines
`get-version` and
`validate-release`, per-target jobs for `build-executable`,
`build-nuget`, `build-pypi` (each a
hook resolved to the caller's own composite action or a hub default), a
duplicated Docker core
as `build-docker` (docker-prepare / docker-build-base hooks), and
`github-release` verbatim in
shape (pattern download, exists gate, `target_commitish`,
consume-then-delete,
  `expect_release_assets`).
- `.github/workflows/build-docker-task.yml` (new hub task): the same
Docker core, published
separately for a caller that wants only the Docker leg. The two files
carry the same job body by
  hand, since a hub task cannot reach a sibling hub task by a `./` path.
- Four hub-default composite actions: `build-executable-default`,
`build-nuget-default`,
`build-pypi-default`, `docker-prepare-default`, authored from today's
leaf catalog snippets.
- The hub's own `publish-release.yml` now calls `build-release-task.yml`
with every target
disabled and `expect_release_assets: false`, exercising the task's
`get-version`,
`validate-release`, and `github-release` jobs on the hub's own release.
- Retires the five catalog snippets the new hub tasks replace
(`build-release-task.yml`, `build-executable-task.yml`,
`build-nugetlibrary-task.yml`,
`build-pypilibrary-task.yml`, `build-docker-task.yml`), updates the
catalog README, and adds a
`retire` gap disposition to `spec/divergences.json` naming the ten
carriers.
- Gives `publish-release.yml` an `interface` contract in
`spec/files.json`
(`requiredJobKeys: ["plan", "validate", "publish"]`) with new
`_selftest` cases in `spec/audit.py`, and
updates `spec/fidelity-model.md`'s "The Workflow Override Seam Contract"
paragraph for the
  stub-era shape.
- Documents the caller-stub shape under a new "Adopting the Release
Chain" section, and updates
the Rollout tracker and `TODO.md` with the settled design decisions and
proof items.
- Rebased onto `#758`'s LF-default flip. Every file this PR touches is
LF now.

## Design choices where the brief left room

- **Docker core duplication.** `build-release-task.yml`'s `build-docker`
job and
`build-docker-task.yml` carry the same job body rather than one calling
the other, since a hub
task cannot nest a sibling hub task by a `./` path.
`build-docker-task.yml` stays published for
  a caller that wants the Docker leg alone.
- **No `publish-release-task.yml`.** A caller stub's
`plan`/`validate`/`publish`/`publish-pypi`
jobs are each a thin call to one hub task or a verbatim OIDC upload, and
the trigger policy
tying them together differs enough across the fleet's shapes
(dispatch-only Docker schedule,
push-gated NuGet/PyPI) that hosting it would only move the same `with:`
block, not remove it.
- **Project-path inputs on the executable/nuget/pypi hub defaults.**
Beyond the fixed
`ref`/`branch`/`smoke` set, the defaults take
`project-file`/`project-dir`, mirroring the
Docker hook's own `image` input, since no live repo's project folder
matches the vanilla
  `Console`/`NuGetLibrary`/`PyPiLibrary` convention literally.
- **`release-assets` hook deferred.** No cataloged repo needs an "extra
files" hook today, so it
is not shipped. The Hook Catalog table row for `build-release-task.yml`
reflects only the
  hooks actually implemented.
- **actionlint's `job_workflow_sha` gap.** `github.job_workflow_sha`
(used to check out the hub at
the caller's pinned commit) is a real GitHub Actions context property
actionlint 1.7.12 does not
yet recognize. Added a scoped `-ignore` flag to the hub's own
`validate-task.yml` actionlint
  step rather than working around the design.

## What this cannot prove from the hub

The hub has no build targets, so every behavioral claim (the hook
fallback path, OIDC NuGet
publishing, the Docker matrix/build-base hooks, a real release through
the new task) is a proof
item on the PhotoCleaner/PlexCleaner pilot adoption, tracked in
`docs/reusable-workflows.md`
Rollout Stage 4. Adoption itself is out of scope for this PR.

## Verification

`spec/validate.py`, `python3 -m unittest discover -s scripts/tests`,
`spec/audit.py --selftest`,
`spec/workflow_reuse.py --selftest`, `scripts/repo_gate.py`,
`scripts/build_dist.py --check`,
`scripts/prose_lint.py` (full check set), `ruff check`/`format --check`,
`mypy`, `actionlint`
(with the one documented `-ignore`), `markdownlint-cli2`, and
`editorconfig-checker` all pass.
Copilot AI lite review requested due to automatic review settings August 16, 2026 20:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Promotes develop to main, completing the hub-hosted reusable-workflow rollout (validation, pure-function tasks, release chain, and type-specific tasks) and flipping the fleet line-ending default to LF, along with associated catalog/spec/test updates.

Changes:

  • Moves/hosts additional reusable workflow “tasks” in the hub (and updates stubs/snippets accordingly), including the release-chain orchestration and codegen task.
  • Updates validation/release workflows and hooks (including hub-local validate hook + defaults) and refreshes related spec/audit/report tooling.
  • Applies the LF line-ending default updates across scripts, schemas, reports, and snippet catalogs.

Reviewed changes

Copilot reviewed 84 out of 204 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
version.json Formatting-only update aligned with LF change.
spec/workflow_reuse.py Writes generated report with LF (no CRLF rewrite).
spec/validate.py Updates commentary to reflect LF default.
spec/third-party-tools.schema.json Formatting-only update aligned with LF change.
spec/secrets.schema.json Formatting-only update aligned with LF change.
spec/project-types.schema.json Formatting-only update aligned with LF change.
spec/files.schema.json Formatting-only update aligned with LF change.
spec/fidelity_honesty.py Writes generated report with LF (no CRLF rewrite).
spec/divergences.schema.json Formatting-only update aligned with LF change.
scripts/tests/test_skills_install.py Adds Linux wrapper sudo-guard tests (via unprivileged user namespaces).
scripts/tests/test_host_gate.py Adds assertions for Docker engine-first probing and pattern behavior.
scripts/tests/test_build_dist.py Updates newline expectations for digest stamp to LF-only.
scripts/skills_install.ps1 Formatting-only update aligned with LF change.
scripts/prose_lint.py Expands/clarifies retired-path exemption set (HUB_HOSTED) and related commentary.
scripts/build_dist.py Forces LF when writing generated manifests/digests to match repo default.
reports/_template.md Formatting-only update aligned with LF change.
repo-config/settings.json Formatting-only update aligned with LF change.
repo-config/operational/develop.json Formatting-only update aligned with LF change.
repo-config/main.json Formatting-only update aligned with LF change.
repo-config/develop.json Formatting-only update aligned with LF change.
pyproject.toml Formatting-only update aligned with LF change.
PSScriptAnalyzerSettings.psd1 Formatting-only update aligned with LF change.
ProjectTemplate.code-workspace Formatting-only update aligned with LF change.
LICENSE Formatting-only update aligned with LF change.
host-tools.json Formatting-only update aligned with LF change.
host-setup/linux/install-skills.sh Refuses sudo runs to avoid installing skills into root’s home/PATH.
host-setup/bootstrap.sh Adds sudo-timestamp action wiring and menu/CLI support.
host-setup/agent-safety/install.ps1 Formatting-only update aligned with LF change.
host-setup/agent-safety/claude-md-fleet.md Formatting-only update aligned with LF change.
host-setup/agent-safety/.markdownlint-cli2.jsonc Formatting-only update aligned with LF change.
HISTORY.md Formatting-only update aligned with LF change.
docs/peer-messaging.md Formatting-only update aligned with LF change.
docs/eol-lf-rollout.md New hub-only checklist for fleet LF rollout tracking.
catalog/snippets/workflows/run-periodic-codegen-pull-request.yml Removes retired snippet (now hub-hosted approach).
catalog/snippets/workflows/run-codegen-pull-request-task.yml Removes retired snippet (task hosted in hub workflows).
catalog/snippets/workflows/README.md Updates snippet catalog mapping for hub-hosted tasks and stubs.
catalog/snippets/workflows/merge-bot-pull-request.yml Adds canonical caller-stub snippet for merge-bot task (pinned).
catalog/snippets/workflows/get-version-task.yml Removes snippet (task hosted in hub workflow).
catalog/snippets/workflows/deploy-site.yml Removes snippet (type-specific task hosted in hub).
catalog/snippets/workflows/build-pypilibrary-task.yml Removes snippet (release-chain now hub-hosted).
catalog/snippets/workflows/build-nugetlibrary-task.yml Removes snippet (release-chain now hub-hosted).
catalog/snippets/workflows/build-executable-task.yml Removes snippet (release-chain now hub-hosted).
catalog/snippets/workflows/build-docker-task.yml Removes snippet (docker core now hub-hosted).
catalog/snippets/workflows/build-datebadge-task.yml Removes snippet (datebadge retired).
catalog/snippets/vscode/README.md Formatting-only update aligned with LF change.
catalog/snippets/vscode/python.jsonc Formatting-only update aligned with LF change.
catalog/snippets/vscode/dotnet.jsonc Formatting-only update aligned with LF change.
catalog/snippets/vscode/docker.jsonc Formatting-only update aligned with LF change.
catalog/snippets/vscode/base.jsonc Formatting-only update aligned with LF change.
catalog/snippets/husky/README.md Updates line-ending guidance to rely on git-level LF enforcement.
catalog/snippets/devcontainer/python/devcontainer.json Formatting-only update aligned with LF change.
catalog/snippets/devcontainer/dotnet/devcontainer.json Formatting-only update aligned with LF change.
catalog/snippets/configs/docker-hub-readme.md Formatting-only update aligned with LF change.
catalog/snippets/configs/dependabot.yml Formatting-only update aligned with LF change.
catalog/snippets/configs/codecov.yml Formatting-only update aligned with LF change.
catalog/README.md Formatting-only update aligned with LF change.
.markdownlint-cli2.jsonc Formatting-only update aligned with LF change.
.gitignore Formatting-only update aligned with LF change.
.github/workflows/run-codegen-pull-request-task.yml Adds hub-hosted reusable workflow for deterministic codegen PR generation.
.github/workflows/publish-release.yml Switches hub publish-release to call build-release-task with targets disabled (tag-only release).
.github/workflows/publish-plan-task.yml Clarifies task is hub-hosted and reused by callers.
.github/workflows/get-version-task.yml Adds hub-hosted reusable workflow for NBGV version computation (+ Prerelease output).
.github/dependabot.yml Formatting-only update aligned with LF change.
.github/actions/validate/action.yml Adds hub-local validate hook (registry/spec/tests/skills freshness).
.github/actions/validate-default/action.yml Adds default no-op validate hook for repos without custom validation.
.github/actions/docker-readme-transform-default/action.yml Adds default docker-readme transform hook.
.github/actions/docker-prepare-default/action.yml Adds default docker-prepare hook emitting a vanilla matrix.
.github/actions/build-pypi-default/action.yml Adds default build-pypi hook (build+lint+test+artifact).
.github/actions/build-executable-default/action.yml Adds default build-executable hook (runtime loop + release asset).
.github/actionlint.yaml Adds scoped ignores for actionlint context-schema gaps on job.workflow_* fields.
.editorconfig-checker.json Formatting-only update aligned with LF change.
.claude-plugin/marketplace.json Formatting-only update aligned with LF change.
.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/test-methodology.md Formatting-only update aligned with LF change.
.claude-plugin/fleet-skills/.source-digest Updates generated digest to match new source state/newline policy.
.claude-plugin/fleet-skills/.claude-plugin/plugin.json Formatting-only update aligned with LF change.
.agents/skills/workflow-ci-contract/references/test-methodology.md Formatting-only update aligned with LF change.
.agents/skills/README.md Formatting-only update aligned with LF change.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .github/workflows/build-release-task.yml
@ptr727
ptr727 merged commit 82fecef into main Aug 16, 2026
8 checks passed
ptr727 added a commit that referenced this pull request Aug 16, 2026
## What

`build-release-task.yml` no longer declares job-level `permissions:` on
`build-nuget` (`id-token: write`) or `github-release` (`contents:
write`, `actions: write`). The caller grants what the targets it enables
need, and `docs/reusable-workflows.md` "Adopting the Release Chain" says
so.

## Why

The hub's first release through the task, dispatched on `main` at
`82fecef` after #768, ended in `startup_failure` (run 31972504539). A
called job's `permissions:` block is validated against the caller's
grant before its `if:` runs (GOVERNANCE.md "Workflow YAML Conventions",
the reusable-workflows bullet), so `build-nuget` asking for `id-token:
write` fails any caller that does not grant it, the hub's own `publish`
job included, even though the job is disabled. `github-release`'s block
has the same shape and would fail every smoke build, since a Dependabot
pull request holds a read-only token that cannot grant `contents:
write`. This is the finding #762 declined on the ground that only
`NuGet/login` consumes the scope: the consumption was never the problem,
the declaration was.

## Verification

actionlint, prose gate, audit self-test, 686 unit tests, markdownlint.
The live proof is the re-dispatched hub release after this promotes,
which is the next step.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Decide whether a lint-only Python subtree pins its uvx tool version or runs @latest

2 participants