Conversation
## 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.
Contributor
There was a problem hiding this comment.
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Promote
developtomain, carrying the remaining stages of the hub-hosted reusable-workflow rollout and the changes that landed beside them:uvx <tool>@latest, since Dependabot tracks the action pins and not a uvx version)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.ymlthroughbuild-release-task.ymlwith every target disabled, which is the live proof thatgithub-releaseruns when its build needs are skipped.Closes #729.
Refs #521 (hub half shipped, the merge-bot adoption sweep is what remains).