From 0fc5bd7d0dbc36218995f842d967e5ee11854f34 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Tue, 1 Sep 2026 21:44:52 -0700 Subject: [PATCH 1/3] Settle Three Carried Sentences Whose Hub Sources Disagreed (#1186) Closes #1158. Each of the three sentences rested on two hub sources contradicting each other, so each is settled at the source rather than reworded in the carrier. ## 1. `secrets: inherit` The ban was real but scoped wrongly. GitHub documents the keyword for a caller in the same organization or enterprise, and this fleet is a personal account, so it is not used on a cross-repository call to a hub task and is available on a same-repository call by local path. The issue assumed nothing in the fleet demonstrates the exception. Reading the downstream repositories rather than only the hub shows otherwise, and both claims were verified against live `main`: - PlexCleaner's `test-pull-request.yml` carries both shapes side by side. Its `validate` job calls `./.github/workflows/validate-task.yml` with `secrets: inherit` to thread `CODECOV_TOKEN` through, and its `smoke-build` job names the two Docker Hub secrets for the hub's pinned `build-release-task.yml`. - NxWitness proves an inherited value actually arrives. Its `publish-release.yml` calls its own `build-docker-task.yml` with `secrets: inherit` and `push: true`, and that task authenticates to Docker Hub from the two inherited names. `WORKFLOW.md` D1.6 and the two `spec/project-types.json` coverage asserts already stated only the verified mechanism, so they are untouched. ## 2. Husky.Net versus `pre-commit` Both sources used the wrong discriminator. The runner is bounded by the host toolchain, never by which languages the checks cover, and either shape carries any language's checks plus the two shared doc gates. This repository is the counterexample that settles it: `source-only` and `docs`, no .NET tool manifest, running Python checks and the doc gates from a `.husky/` directory enabled as a plain `core.hooksPath`. Stated one-directionally, since the Husky snippet needs a host Python interpreter too. This closes the concrete gap the issue names, where a Docker, config, or docs repository had no mechanism both sources agreed applied to it. ## 3. A carried file edited locally The old wording licensed a local fix alongside the upstream proposal, and it now sends the change upstream instead. `AGENTS.md` routed a project's own API conventions into a section of its own `AGENTS.md`, which `spec/section-model.md` calls drift to reconcile, so they go to that project's own `CODESTYLE.md`, which `AGENTS.md` already routes every agent to. What a conformance audit detects at each fidelity tier is new text in the hub-only `spec/fidelity-model.md`, where its reader can verify it. The load-bearing part: the `intent` staleness advisory cannot fire on a local edit at all, since it compares last-change dates and a local edit makes the copy newer. Confirmed in `check_intent_staleness`, which returns empty when `hub_date <= repo_date`. An earlier draft put that per-tier account in the carried `GOVERNANCE.md` section. That was wrong three ways: a carrying repository runs none of those checks, fidelity is declared per section rather than per file, so the text described itself incorrectly, and the advisory claim was false. The carried section now says only what a carrier can act on. ## Also in this change `catalog/snippets/husky/pre-commit` probed `python3` before `py -3` while its own comment claimed to mirror `spec/host-tools.json`'s declared order. The hub's own hook was corrected in 44ae00b and this snippet was missed, and the reversed order defeats the Windows case the adjacent comment describes. Settling the hook rule invalidated prose elsewhere, found by the "grep for prose asserting the old behavior" sweep: `STANDUP.md`, `catalog/README.md`, `docs/pre-commit-hooks-rollout.md`, both language codestyle skills, and `spec/third-party-tools.json`. `WORKFLOW.md` carried the same stale `secrets: inherit` justification the hub-only doc had, and it is the copy a downstream repository actually reads. ## Verification `spec/validate.py`, `prose_lint.py` whole-tree and diff-scoped, `repo_gate.py` all three checks, `build_dist.py --check`, `spec/audit.py --selftest`, and 1046 unit tests all pass. `shellcheck -s sh` and `shfmt` clean on the edited hook. Every changed carried canonical unit is covered by a recorded review pass. `GOVERNANCE.md` "Durable Knowledge and Self-Improvement" is a verbatim section, so this re-vendors fleet-wide and the audit drift wave that follows is the mechanism working. ## Follow-ups, not in this change - `ptr727/LanguageTags` carries a `Library API Conventions` section in its `AGENTS.md`, the example the old preamble sanctioned, which this change declares drift with no migration tracked. - `canonical_review.py` derives no `> (preamble)` unit for `AGENTS.md`, so this change's most widely carried edit passed the gate unexamined. - `repo-config/configure.sh` probes `python3` before `py -3`, the inverse of the declared order, on a documented 3.7 floor that justifies the floor but not the order. - Roughly a dozen pre-existing defects the review passes surfaced in the units this change touches, none introduced here. ## Summary by CodeRabbit - **Documentation** - Clarified when to use Husky.Net, pre-commit, or custom hooks based on repository tooling. - Expanded guidance for mixed Python/.NET and non-Python repositories. - Documented deployment secret handling for authenticated site checks and cross-repository workflows. - Clarified governance ownership, project documentation conventions, and local-edit fidelity reporting. - **Workflow Improvements** - Updated hook behavior to probe Python interpreters in the documented order. - Refined shared documentation and end-of-line checks for repositories without Python. - Refreshed review records and coverage reporting. --- .agents/skills/agent-conduct/SKILL.md | 2 +- .agents/skills/dotnet-codestyle/SKILL.md | 20 +++++-- .agents/skills/python-codestyle/SKILL.md | 12 +++- .claude-plugin/fleet-skills/.source-digest | 2 +- .../skills/agent-conduct/SKILL.md | 2 +- .../skills/dotnet-codestyle/SKILL.md | 20 +++++-- .../skills/python-codestyle/SKILL.md | 12 +++- .github/copilot-instructions.md | 3 +- .github/skills/agent-conduct/SKILL.md | 2 +- .github/skills/dotnet-codestyle/SKILL.md | 20 +++++-- .github/skills/python-codestyle/SKILL.md | 12 +++- .github/workflows/deploy-site-task.yml | 2 +- AGENTS.md | 2 +- CODESTYLE.md | 2 +- GOVERNANCE.md | 6 +- STANDUP.md | 2 +- WORKFLOW.md | 2 +- catalog/README.md | 2 +- catalog/snippets/husky/README.md | 12 +++- catalog/snippets/husky/pre-commit | 12 ++-- .../pre-commit/.pre-commit-config.yaml | 4 +- catalog/snippets/pre-commit/README.md | 29 ++++++--- docs/pre-commit-hooks-rollout.md | 5 +- docs/reusable-workflows.md | 5 +- reports/canonical-review.json | 60 ++++++++++++------- reports/canonical-review.md | 6 +- spec/audit.py | 7 ++- spec/fidelity-model.md | 13 ++++ spec/third-party-tools.json | 2 +- 29 files changed, 185 insertions(+), 95 deletions(-) diff --git a/.agents/skills/agent-conduct/SKILL.md b/.agents/skills/agent-conduct/SKILL.md index 9f20255f..d02e4e04 100644 --- a/.agents/skills/agent-conduct/SKILL.md +++ b/.agents/skills/agent-conduct/SKILL.md @@ -38,7 +38,7 @@ Claims about a pull request being reviewed, clean, or mergeable are owned by the ## When a Failure Surfaces a Lesson - **Durable knowledge lands in the committed docs, not in agent memory**, as part of the change that surfaced it, per `GOVERNANCE.md` "Durable Knowledge and Self-Improvement". Memory does not survive a new session or machine, so it holds only environment nuance and in-flight state. -- **Where the governing doc is carried from the hub, file the finding against `ptr727/ProjectTemplate`** rather than only patching it locally. A local fix leaves every sibling repo with the same trap. Search open and closed issues first, then update the matching issue or file a new one. +- **Where the governing doc is carried from the hub, file the finding against `ptr727/ProjectTemplate`.** Patching the local copy leaves every sibling repo with the same trap. Search open and closed issues first, then update the matching issue or file a new one. - **A review flags an instance, so fix the class**: sweep for the siblings before replying, because reviewers sample rather than enumerate. - **A rule that keeps needing restating** is usually a stale or missing skills install, so run `python3 scripts/skills_install.py --report` from a hub checkout (the `fleet-conformance-check` skill) before concluding the rule does not exist. diff --git a/.agents/skills/dotnet-codestyle/SKILL.md b/.agents/skills/dotnet-codestyle/SKILL.md index 2cc81eef..db41583e 100644 --- a/.agents/skills/dotnet-codestyle/SKILL.md +++ b/.agents/skills/dotnet-codestyle/SKILL.md @@ -49,8 +49,14 @@ All builds must complete without warnings, enforced three ways: surfaced as a warning fails the build and must be fixed or deliberately suppressed at the narrowest scope that fits (see Analyzer suppressions below), never left to accumulate. - **CI lint backstop.** CI runs the clean-compile checks on every PR as the authoritative gate. - The .NET mechanism for a local hook is Husky.Net, wired from the canonical - `catalog/snippets/husky/` config in the hub, hub-local and not carried into every fleet repo. + Husky.Net is wired from the canonical `catalog/snippets/husky/` config in the hub, hub-local + and not carried into every fleet repo. Its hook needs a .NET tool manifest declaring + Husky.Net, which the snippet does not ship. A repo keeping no such manifest takes the other + canonical shape, `catalog/snippets/pre-commit/`, and so does a repo that simply prefers the + `pre-commit` framework. Each shape carries whichever language checks its own repo keeps. A repo may also wire an equivalent hook of its own at `.husky/pre-commit`, + enabled with `core.hooksPath` and sourcing nothing, since the husky snippet's own hook sources + a file only `dotnet husky install` generates. That path and `.pre-commit-config.yaml` are the + two the audit reads. GOVERNANCE.md's hub-only "Running the Linters Locally (Known-Working Invocations)" section carries the obligation itself, what the hook must cover, its audit treatment, and the per-clone enablement steps. @@ -81,7 +87,7 @@ in place. ### Build tasks Run these from VS Code's task runner (Terminal -> Run Task) or an agent's task-running tool. The -three clean-compile tasks are carried verbatim, and a repo adds its own convenience tasks (tool +three clean-compile tasks are carried unchanged, and a repo adds its own convenience tasks (tool updates, dependency upgrades, benchmarks) on top: - `.NET Build`: build with diagnostic verbosity *(clean-compile)* @@ -97,9 +103,11 @@ updates, dependency upgrades, benchmarks) on top: `dotnet format style --verify-no-changes --severity=info --verbosity=detailed`. - **`dotnet-outdated-tool`** checks for dependency updates, and Nerdbank.GitVersioning owns version management. -- CI is the authoritative lint backstop. The .NET mechanism for a local pre-commit hook is - Husky.Net, wired from `catalog/snippets/husky/` in the hub, hub-local and not carried into every - fleet repo, and covering the shared doc gates alongside the language checks. +- CI is the authoritative lint backstop. A repo already keeping a .NET tool manifest declaring + Husky.Net wires its local pre-commit hook from `catalog/snippets/husky/` in the hub, hub-local + and not carried into every fleet repo, covering the shared doc gates alongside the language + checks. A repo keeping none, or one preferring that runner, takes `catalog/snippets/pre-commit/` + instead. - **Required VS Code extensions**: CSharpier, markdownlint, CSpell. Use the workspace settings without overrides. diff --git a/.agents/skills/python-codestyle/SKILL.md b/.agents/skills/python-codestyle/SKILL.md index 210a67b7..160bf0bf 100644 --- a/.agents/skills/python-codestyle/SKILL.md +++ b/.agents/skills/python-codestyle/SKILL.md @@ -96,9 +96,15 @@ those. These are documented commands, and the hub's `vscode-tasks-python.json` s VS Code tasks mirror that the fleet baseline expects. Every command-executing task in it is `type: process`, and every aggregator is `dependsOn`-only. Neither chains with `&&`, so the mirror runs the same on any task shell. CI runs the same clean-compile commands as the authoritative -backstop. The Python mechanism for a local hook is the `pre-commit` framework, wired from the -canonical `catalog/snippets/pre-commit/` directory, hub-local and not carried into every fleet -repo. That directory's own README names the second file to copy alongside the config. +backstop. A repo that keeps no .NET tool manifest declaring Husky.Net, or one that prefers the +`pre-commit` framework, wires its local hook from the canonical `catalog/snippets/pre-commit/` directory, +hub-local and not carried into every fleet repo. Any repo may instead wire an equivalent hook of +its own at `.husky/pre-commit`, enabled with `core.hooksPath` and sourcing nothing. That path and +`.pre-commit-config.yaml` are the two the audit reads. The runner is bounded by the toolchain the +repo already keeps rather than by the languages the hook checks, so a repo keeping a Husky.Net +manifest may run these same Python checks from the `catalog/snippets/husky/` shape instead. Each +shape carries whichever language checks its own repo keeps. The `pre-commit` directory's own +README names the second file to copy alongside the config. GOVERNANCE.md's hub-only "Running the Linters Locally (Known-Working Invocations)" section carries the obligation itself, what the hook must cover, its audit treatment, and the per-clone enablement steps. diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index 008f19b6..c2108a0c 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -fe834763b58313f7 +c7efdbdc80111e8f diff --git a/.claude-plugin/fleet-skills/skills/agent-conduct/SKILL.md b/.claude-plugin/fleet-skills/skills/agent-conduct/SKILL.md index 9f20255f..d02e4e04 100644 --- a/.claude-plugin/fleet-skills/skills/agent-conduct/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/agent-conduct/SKILL.md @@ -38,7 +38,7 @@ Claims about a pull request being reviewed, clean, or mergeable are owned by the ## When a Failure Surfaces a Lesson - **Durable knowledge lands in the committed docs, not in agent memory**, as part of the change that surfaced it, per `GOVERNANCE.md` "Durable Knowledge and Self-Improvement". Memory does not survive a new session or machine, so it holds only environment nuance and in-flight state. -- **Where the governing doc is carried from the hub, file the finding against `ptr727/ProjectTemplate`** rather than only patching it locally. A local fix leaves every sibling repo with the same trap. Search open and closed issues first, then update the matching issue or file a new one. +- **Where the governing doc is carried from the hub, file the finding against `ptr727/ProjectTemplate`.** Patching the local copy leaves every sibling repo with the same trap. Search open and closed issues first, then update the matching issue or file a new one. - **A review flags an instance, so fix the class**: sweep for the siblings before replying, because reviewers sample rather than enumerate. - **A rule that keeps needing restating** is usually a stale or missing skills install, so run `python3 scripts/skills_install.py --report` from a hub checkout (the `fleet-conformance-check` skill) before concluding the rule does not exist. diff --git a/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md b/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md index 2cc81eef..db41583e 100644 --- a/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md @@ -49,8 +49,14 @@ All builds must complete without warnings, enforced three ways: surfaced as a warning fails the build and must be fixed or deliberately suppressed at the narrowest scope that fits (see Analyzer suppressions below), never left to accumulate. - **CI lint backstop.** CI runs the clean-compile checks on every PR as the authoritative gate. - The .NET mechanism for a local hook is Husky.Net, wired from the canonical - `catalog/snippets/husky/` config in the hub, hub-local and not carried into every fleet repo. + Husky.Net is wired from the canonical `catalog/snippets/husky/` config in the hub, hub-local + and not carried into every fleet repo. Its hook needs a .NET tool manifest declaring + Husky.Net, which the snippet does not ship. A repo keeping no such manifest takes the other + canonical shape, `catalog/snippets/pre-commit/`, and so does a repo that simply prefers the + `pre-commit` framework. Each shape carries whichever language checks its own repo keeps. A repo may also wire an equivalent hook of its own at `.husky/pre-commit`, + enabled with `core.hooksPath` and sourcing nothing, since the husky snippet's own hook sources + a file only `dotnet husky install` generates. That path and `.pre-commit-config.yaml` are the + two the audit reads. GOVERNANCE.md's hub-only "Running the Linters Locally (Known-Working Invocations)" section carries the obligation itself, what the hook must cover, its audit treatment, and the per-clone enablement steps. @@ -81,7 +87,7 @@ in place. ### Build tasks Run these from VS Code's task runner (Terminal -> Run Task) or an agent's task-running tool. The -three clean-compile tasks are carried verbatim, and a repo adds its own convenience tasks (tool +three clean-compile tasks are carried unchanged, and a repo adds its own convenience tasks (tool updates, dependency upgrades, benchmarks) on top: - `.NET Build`: build with diagnostic verbosity *(clean-compile)* @@ -97,9 +103,11 @@ updates, dependency upgrades, benchmarks) on top: `dotnet format style --verify-no-changes --severity=info --verbosity=detailed`. - **`dotnet-outdated-tool`** checks for dependency updates, and Nerdbank.GitVersioning owns version management. -- CI is the authoritative lint backstop. The .NET mechanism for a local pre-commit hook is - Husky.Net, wired from `catalog/snippets/husky/` in the hub, hub-local and not carried into every - fleet repo, and covering the shared doc gates alongside the language checks. +- CI is the authoritative lint backstop. A repo already keeping a .NET tool manifest declaring + Husky.Net wires its local pre-commit hook from `catalog/snippets/husky/` in the hub, hub-local + and not carried into every fleet repo, covering the shared doc gates alongside the language + checks. A repo keeping none, or one preferring that runner, takes `catalog/snippets/pre-commit/` + instead. - **Required VS Code extensions**: CSharpier, markdownlint, CSpell. Use the workspace settings without overrides. diff --git a/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md b/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md index 210a67b7..160bf0bf 100644 --- a/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md @@ -96,9 +96,15 @@ those. These are documented commands, and the hub's `vscode-tasks-python.json` s VS Code tasks mirror that the fleet baseline expects. Every command-executing task in it is `type: process`, and every aggregator is `dependsOn`-only. Neither chains with `&&`, so the mirror runs the same on any task shell. CI runs the same clean-compile commands as the authoritative -backstop. The Python mechanism for a local hook is the `pre-commit` framework, wired from the -canonical `catalog/snippets/pre-commit/` directory, hub-local and not carried into every fleet -repo. That directory's own README names the second file to copy alongside the config. +backstop. A repo that keeps no .NET tool manifest declaring Husky.Net, or one that prefers the +`pre-commit` framework, wires its local hook from the canonical `catalog/snippets/pre-commit/` directory, +hub-local and not carried into every fleet repo. Any repo may instead wire an equivalent hook of +its own at `.husky/pre-commit`, enabled with `core.hooksPath` and sourcing nothing. That path and +`.pre-commit-config.yaml` are the two the audit reads. The runner is bounded by the toolchain the +repo already keeps rather than by the languages the hook checks, so a repo keeping a Husky.Net +manifest may run these same Python checks from the `catalog/snippets/husky/` shape instead. Each +shape carries whichever language checks its own repo keeps. The `pre-commit` directory's own +README names the second file to copy alongside the config. GOVERNANCE.md's hub-only "Running the Linters Locally (Known-Working Invocations)" section carries the obligation itself, what the hook must cover, its audit treatment, and the per-clone enablement steps. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b39ce5c4..e69b1dea 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -22,8 +22,7 @@ put a release-bump magnitude in the title. The full contract is in Follow the fidelity declared for the file. A byte-locked reference to shared infrastructure that this repository does not carry is intentional, not a broken link. Raise substantive defects in -canonical content, but locate the fix at its canonical source instead of proposing a local edit -that its fidelity rejects. +canonical content, but locate the fix at its canonical source instead of proposing a local edit. ## GitHub Copilot Review Runbook diff --git a/.github/skills/agent-conduct/SKILL.md b/.github/skills/agent-conduct/SKILL.md index 9f20255f..d02e4e04 100644 --- a/.github/skills/agent-conduct/SKILL.md +++ b/.github/skills/agent-conduct/SKILL.md @@ -38,7 +38,7 @@ Claims about a pull request being reviewed, clean, or mergeable are owned by the ## When a Failure Surfaces a Lesson - **Durable knowledge lands in the committed docs, not in agent memory**, as part of the change that surfaced it, per `GOVERNANCE.md` "Durable Knowledge and Self-Improvement". Memory does not survive a new session or machine, so it holds only environment nuance and in-flight state. -- **Where the governing doc is carried from the hub, file the finding against `ptr727/ProjectTemplate`** rather than only patching it locally. A local fix leaves every sibling repo with the same trap. Search open and closed issues first, then update the matching issue or file a new one. +- **Where the governing doc is carried from the hub, file the finding against `ptr727/ProjectTemplate`.** Patching the local copy leaves every sibling repo with the same trap. Search open and closed issues first, then update the matching issue or file a new one. - **A review flags an instance, so fix the class**: sweep for the siblings before replying, because reviewers sample rather than enumerate. - **A rule that keeps needing restating** is usually a stale or missing skills install, so run `python3 scripts/skills_install.py --report` from a hub checkout (the `fleet-conformance-check` skill) before concluding the rule does not exist. diff --git a/.github/skills/dotnet-codestyle/SKILL.md b/.github/skills/dotnet-codestyle/SKILL.md index 2cc81eef..db41583e 100644 --- a/.github/skills/dotnet-codestyle/SKILL.md +++ b/.github/skills/dotnet-codestyle/SKILL.md @@ -49,8 +49,14 @@ All builds must complete without warnings, enforced three ways: surfaced as a warning fails the build and must be fixed or deliberately suppressed at the narrowest scope that fits (see Analyzer suppressions below), never left to accumulate. - **CI lint backstop.** CI runs the clean-compile checks on every PR as the authoritative gate. - The .NET mechanism for a local hook is Husky.Net, wired from the canonical - `catalog/snippets/husky/` config in the hub, hub-local and not carried into every fleet repo. + Husky.Net is wired from the canonical `catalog/snippets/husky/` config in the hub, hub-local + and not carried into every fleet repo. Its hook needs a .NET tool manifest declaring + Husky.Net, which the snippet does not ship. A repo keeping no such manifest takes the other + canonical shape, `catalog/snippets/pre-commit/`, and so does a repo that simply prefers the + `pre-commit` framework. Each shape carries whichever language checks its own repo keeps. A repo may also wire an equivalent hook of its own at `.husky/pre-commit`, + enabled with `core.hooksPath` and sourcing nothing, since the husky snippet's own hook sources + a file only `dotnet husky install` generates. That path and `.pre-commit-config.yaml` are the + two the audit reads. GOVERNANCE.md's hub-only "Running the Linters Locally (Known-Working Invocations)" section carries the obligation itself, what the hook must cover, its audit treatment, and the per-clone enablement steps. @@ -81,7 +87,7 @@ in place. ### Build tasks Run these from VS Code's task runner (Terminal -> Run Task) or an agent's task-running tool. The -three clean-compile tasks are carried verbatim, and a repo adds its own convenience tasks (tool +three clean-compile tasks are carried unchanged, and a repo adds its own convenience tasks (tool updates, dependency upgrades, benchmarks) on top: - `.NET Build`: build with diagnostic verbosity *(clean-compile)* @@ -97,9 +103,11 @@ updates, dependency upgrades, benchmarks) on top: `dotnet format style --verify-no-changes --severity=info --verbosity=detailed`. - **`dotnet-outdated-tool`** checks for dependency updates, and Nerdbank.GitVersioning owns version management. -- CI is the authoritative lint backstop. The .NET mechanism for a local pre-commit hook is - Husky.Net, wired from `catalog/snippets/husky/` in the hub, hub-local and not carried into every - fleet repo, and covering the shared doc gates alongside the language checks. +- CI is the authoritative lint backstop. A repo already keeping a .NET tool manifest declaring + Husky.Net wires its local pre-commit hook from `catalog/snippets/husky/` in the hub, hub-local + and not carried into every fleet repo, covering the shared doc gates alongside the language + checks. A repo keeping none, or one preferring that runner, takes `catalog/snippets/pre-commit/` + instead. - **Required VS Code extensions**: CSharpier, markdownlint, CSpell. Use the workspace settings without overrides. diff --git a/.github/skills/python-codestyle/SKILL.md b/.github/skills/python-codestyle/SKILL.md index 210a67b7..160bf0bf 100644 --- a/.github/skills/python-codestyle/SKILL.md +++ b/.github/skills/python-codestyle/SKILL.md @@ -96,9 +96,15 @@ those. These are documented commands, and the hub's `vscode-tasks-python.json` s VS Code tasks mirror that the fleet baseline expects. Every command-executing task in it is `type: process`, and every aggregator is `dependsOn`-only. Neither chains with `&&`, so the mirror runs the same on any task shell. CI runs the same clean-compile commands as the authoritative -backstop. The Python mechanism for a local hook is the `pre-commit` framework, wired from the -canonical `catalog/snippets/pre-commit/` directory, hub-local and not carried into every fleet -repo. That directory's own README names the second file to copy alongside the config. +backstop. A repo that keeps no .NET tool manifest declaring Husky.Net, or one that prefers the +`pre-commit` framework, wires its local hook from the canonical `catalog/snippets/pre-commit/` directory, +hub-local and not carried into every fleet repo. Any repo may instead wire an equivalent hook of +its own at `.husky/pre-commit`, enabled with `core.hooksPath` and sourcing nothing. That path and +`.pre-commit-config.yaml` are the two the audit reads. The runner is bounded by the toolchain the +repo already keeps rather than by the languages the hook checks, so a repo keeping a Husky.Net +manifest may run these same Python checks from the `catalog/snippets/husky/` shape instead. Each +shape carries whichever language checks its own repo keeps. The `pre-commit` directory's own +README names the second file to copy alongside the config. GOVERNANCE.md's hub-only "Running the Linters Locally (Known-Working Invocations)" section carries the obligation itself, what the hook must cover, its audit treatment, and the per-clone enablement steps. diff --git a/.github/workflows/deploy-site-task.yml b/.github/workflows/deploy-site-task.yml index 51bcede7..9b743724 100644 --- a/.github/workflows/deploy-site-task.yml +++ b/.github/workflows/deploy-site-task.yml @@ -27,7 +27,7 @@ on: required: true type: string secrets: - # The one credential that crosses this reusable workflow's boundary as a named secret. + # The credentials that cross this reusable workflow's boundary as named secrets. # The host address, the user, and the known-hosts value are GitHub Environment variables instead, since they are integrity-critical but not confidential. # The deploy job below reads them directly through its own environment binding. DEPLOY_SSH_PRIVATE_KEY: diff --git a/AGENTS.md b/AGENTS.md index 2359cd0f..18191e54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ This file is the entry point every coding agent reads first, and it holds only three things: the bootstrap that says where the canonical rules live and which procedure to follow for the state this repository is actually in, the rules for managing context and delegation, which apply to every task, and a map of where every other rule lives. The rule text itself is in [`GOVERNANCE.md`](./GOVERNANCE.md), one section per topic. Code style lives in [`CODESTYLE.md`](./CODESTYLE.md) (a General section plus per-language sections for .NET, Python, and Shell, the language sections packaged as the `dotnet-codestyle`, `python-codestyle`, and `shell-codestyle` Skills), and the CI/CD workflow contract in [`WORKFLOW.md`](./WORKFLOW.md). -Treat this file and `GOVERNANCE.md` as authoritative for cross-cutting rules, and do not restate their rules elsewhere. A project's **project-specific conventions and public-API/behavioral contracts** (e.g. a "Library API Conventions" section) live in that project's own `AGENTS.md`, **not** in [`.github/copilot-instructions.md`](./.github/copilot-instructions.md), because that file targets GitHub Copilot / VS Code specifically, while this file and `GOVERNANCE.md` are the agent-agnostic ones every coding agent is directed to read, so any rule a reviewer must honor has to live in one of those two files to be provider-independent. +Treat this file and `GOVERNANCE.md` as authoritative for cross-cutting rules, and do not restate their rules elsewhere. A project's **project-specific conventions and public-API/behavioral contracts** live in that project's own topical docs. They do **not** go in [`.github/copilot-instructions.md`](./.github/copilot-instructions.md), which targets GitHub Copilot and VS Code specifically. A rule a reviewer must honor therefore has to sit in an agent-agnostic file to be provider-independent. A section of its own in this file is not the alternative. An undeclared section here is drift to reconcile rather than a local liberty. ## Fleet Bootstrap diff --git a/CODESTYLE.md b/CODESTYLE.md index b9b73cd8..9e478e32 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -18,7 +18,7 @@ Each language defines a **clean-compile** verification: the combination of build - **Run it after every code change, and it is not the whole gate.** The relevant language's clean-compile must pass before you commit. CI runs those same language checks as a backstop **plus everything else its validation workflow runs**, and all of it reports into the one required status, so a green clean-compile does not predict a green CI. That remainder is at least the doc-lint set (markdownlint, cspell, actionlint, `editorconfig-checker`) and whatever spec, config, and script gates the repo carries, so read the workflow for the full list rather than assuming this sentence enumerates it. What has to pass before a push is the repo's **whole** lint gate, per [GOVERNANCE.md "Verification Discipline"][governance-verification-discipline]. Each linter's known-working invocation is in `GOVERNANCE.md` "Running the Linters Locally (Known-Working Invocations)", a hub-only section read in a hub checkout rather than carried into every fleet repo. - **The named task definition is the canonical spec** - its exact command sequence, arguments, and strictness. You may run it through the VS Code task **or** by invoking the equivalent native commands directly, and either is fine **only if the sequence, arguments, and strictness match exactly**. No shortcuts and no more-lenient options (for example, never drop `--verify-no-changes` or loosen a `--severity`). -- **A working local commit/pre-commit gate is strongly suggested, not the repo's free choice to skip.** No single hook runner fits every language (a `dotnet`-tool runner like Husky.Net suits .NET but not Python), so the *mechanism* stays the repo's choice: Husky.Net for .NET, `pre-commit` for Python, canonical configs for both in `catalog/snippets/` in the hub, not a repo-relative path since it is hub-local and not carried into every fleet repo. What that gate must cover, and what its absence means for the audit, is `GOVERNANCE.md` "Running the Linters Locally (Known-Working Invocations)", the same hub-only section, not restated here. Keeping a working gate is not drift. +- **A working local commit/pre-commit gate is strongly suggested, not the repo's free choice to skip.** The *mechanism* is bounded by the toolchain the repo already keeps rather than by which languages its checks cover, and two canonical shapes carry it. Husky.Net runs from a .NET tool manifest declaring it, so a repo that keeps no such manifest uses the `pre-commit` framework instead. Any repo may also wire an equivalent hook of its own at `.husky/pre-commit`, enabled with `core.hooksPath` and sourcing nothing. Canonical shapes for both live in `catalog/snippets/` in the hub, not a repo-relative path since it is hub-local and not carried into every fleet repo. What that gate must cover, its per-clone enablement steps, and what its absence means for the audit, is `GOVERNANCE.md` "Running the Linters Locally (Known-Working Invocations)", the same hub-only section, not restated here. Keeping a working gate is not drift. ### Analyzer Diagnostics and Suppressions diff --git a/GOVERNANCE.md b/GOVERNANCE.md index e6ce1b3a..de286e7f 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -16,7 +16,7 @@ The specific rules in this file implement a few governing principles. Read these ## Durable Knowledge and Self-Improvement - **Durable knowledge lives in the committed docs, not in agent memory.** Anything a future agent must honor (a rule, a contract, a hard-won gotcha, a pattern worth repeating or one to avoid) belongs in a committed governance file (`GOVERNANCE.md` for a cross-cutting rule, `AGENTS.md`, `CODESTYLE.md`, `WORKFLOW.md`, or a committed backlog the repository already keeps). Agent memory does not survive a new session, a new machine, or a new environment, so it holds only environment-specific nuance and in-flight session state, never anything whose loss on reset would matter. A durable lesson left only in memory is lost to the next agent. -- **Keep the governance current as you work.** When work surfaces something durable (a rule worth enforcing, a recurring gotcha, a positive pattern to repeat, a negative one to design out), record it in the governance docs as part of that change, rather than leaving it in a local note or routing around it with a one-off workaround. Where the governing doc is carried from a template this repo cannot edit directly, propose the change upstream instead of only fixing it locally. Governance is not static: it improves by agents folding good patterns in and designing bad ones out. +- **Keep the governance current as you work.** When work surfaces something durable (a rule worth enforcing, a recurring gotcha, a positive pattern to repeat, a negative one to design out), record it in the governance docs as part of that change, rather than leaving it in a local note or routing around it with a one-off workaround. Where the governing doc is carried from a template this repo cannot edit directly, propose the change upstream rather than patching the local copy. A local patch leaves every sibling repo with the same trap. Governance is not static: it improves by agents folding good patterns in and designing bad ones out. - **A durable rule earns a mechanical hook only where a hook can actually decide it, otherwise it stays prose.** Three conditions together, not any one alone. The failure recurs even after the governing prose was demonstrably read and understood, so it is not a discovery or loading problem a structural fix (getting the rule into context at all) would already solve. The triggering shape is decidable from the tool call's own text, arguments, and working directory alone, with no semantic or contextual judgment required. And the failure is destructive or hard to reverse rather than a quality miss. A worktree-isolation lapse met all three (it recurred under prose the agent had already read, "is this command's target a primary checkout" is a plain directory comparison, and the harm is another task's swept or reverted work), so it was promoted to a `gh-write-guard` hook rule. A skill's own trigger going unread by the session at all, by contrast, is a loading problem, fixed by getting the rule into context (the `CLAUDE.md` importing `AGENTS.md`), not by a hook. And "was this review finding actually evidence-backed" fails the second condition outright: a hook sees only the command text, never the judgment call itself, so it can only ever nag, not decide, and that class of rule stays prose and a chained Skill trigger. Those three conditions gate promotion to a **host** hook, the involuntary layer that fires in every session under the maintainer's own credentials and that only the maintainer can grant an exemption from, which is why the bar there is destructive harm. A **committed** hook in the repository's own tree is a third layer between prose and that one, and it is earned on weaker grounds: it is opt-in per clone, visible in the tree, bypassable by design, and it therefore fits a rule whose harm is a quality miss rather than a destruction. The second condition still binds it, since a hook that cannot decide its own trigger is a hook that nags, so what earns the layer is finding the decidable half of a rule whose other half is judgment. The local-review rule under "Verification Discipline" is the worked example: whether a review's findings were rightly disposed of is judgment no hook can decide and stays prose, while whether a review pass ran over exactly the content being pushed is a receipt comparison, which the hub's own `.husky/pre-push` decides. This section keeps the full rules and is surfaced at its decision moment by the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. @@ -244,8 +244,8 @@ CI runs the full lint set, but run the linters locally before pushing to catch i **Each surface runs the lint with the tool that fits it, all from the same config files** (`.markdownlint-cli2.jsonc`, `cspell.json`, `.editorconfig`): - **CI (authoritative)** runs **markdownlint-cli2**, **cspell**, and **actionlint** as pinned action wrappers (Dependabot bumps them), plus **editorconfig-checker** via Docker `:latest` (its action only installs the CLI, so the Docker one-liner is what actually runs the check), **shellcheck** and **shfmt** the same way for a repo that carries a shell script, and, **for a repo that carries `.ps1` files**, **PSScriptAnalyzer** the same way (none of the three has an action). markdownlint covers all `**/*.md`, and **cspell is scoped to `README.md` + `HISTORY.md`** (see [CODESTYLE.md](./CODESTYLE.md) "Markdown and Spelling" for why), matching the cspell one-liner below. A "shell script" here is a tracked `.sh` file, plus a tracked, extension-less file whose shebang names bash or sh, the shape a script meant to run as a bare command takes: a bare `*.sh` glob misses that second case, so both discovery paths run before either tool does. This whole block is the hub's `validate-task.yml` reusable workflow, so a fleet repo reaches it rather than carrying a copy of these steps. -- **A working local hook is strongly suggested fleet-wide, and its absence is a measured audit finding, not an invisible gap.** `spec/project-types.json`'s `parity.hooks` check reads this section for its rationale, judged by hand during an `AUDIT.md` run like every sibling check in its dimension, never mechanized by `spec/audit.py`: a repo with no local hook mechanism wired at all is a `linter-parity` defect, the same severity a missing markdownlint config already gets, while a repo mid-convergence (below) stays operational. CI remains the authoritative run regardless. Two catalog snippets carry the canonical shape, `catalog/snippets/husky/` (Husky.Net, for .NET or any project including Python) and `catalog/snippets/pre-commit/` (the Python `pre-commit` framework, for a repo with no `.husky/` tree), each carrying a copy of `catalog/snippets/hub-fetch-run.py` alongside it. -- **The hook** runs **language formatting** and the **diff-scoped doc gates**, never Docker, so it stays fast. The formatting half is whatever the repo's own language needs, CSharpier and `dotnet format` for .NET or ruff and the repo's type checker for Python, via native tooling. A repo adds each half once its tree passes that half, since a gate that fails on the corpus it guards blocks every commit from the moment it lands, so a hook running one half is a repo mid-convergence rather than a repo out of conformance. The doc half runs each gate at the scope that fits it. The prose gate is scoped to the working tree diff against `HEAD` rather than swept over the tree, which is the difference between about 2.2 seconds and about 0.13 and is what makes it affordable in a hook at all. That scope is the working tree rather than the staged index, so a partially staged file is judged on all of its edits, not only the staged ones, and CI re-checks the whole tree regardless, which is what makes that scope affordable in a hook. A whole-repo check belongs there too when it is already fast and takes no file list, which the line-ending consistency check is, so scope is a property of the gate rather than a rule the hook applies to all of them. `repo_gate.py --check sha-pin` stays out of the hook regardless, since it resolves a same-owner pin against the GitHub API. +- **A working local hook is strongly suggested fleet-wide, and its absence is a measured audit finding, not an invisible gap.** `spec/project-types.json`'s `parity.hooks` check reads this section for its rationale, judged by hand during an `AUDIT.md` run like every sibling check in its dimension, never mechanized by `spec/audit.py`: a repo with no local hook mechanism wired at all is a `linter-parity` defect, the same severity a missing markdownlint config already gets, while a repo mid-convergence (below) stays operational. CI remains the authoritative run regardless. Two catalog snippets carry the canonical shape, each with a copy of `catalog/snippets/hub-fetch-run.py` alongside it. `catalog/snippets/husky/` is Husky.Net, for a repo that already keeps a .NET tool manifest declaring it, since `dotnet tool restore` then `dotnet husky install` is what generates the `.husky/_/husky.sh` its hook sources, and the snippet ships no manifest of its own. `catalog/snippets/pre-commit/` is the `pre-commit` framework, for a repo without one and for any repo that prefers it. Neither snippet is scoped to a language by what it runs. Each carries the repo's own language checks plus the two shared doc gates. A repo drops the block for a language it does not have. A Docker, config, or docs repo therefore wires the doc gates alone, which is a mechanism rather than the absence of one. Which snippet a repo starts from is bounded by the toolchain it already keeps, never by which languages its checks cover. A snippet is a starting point rather than the only shape: a repo may wire an equivalent hook of its own at `.husky/pre-commit`, enabled with `core.hooksPath` and sourcing nothing, which is what this repository does. That path matters, since `parity.hooks` reads the tracked file at `.husky/pre-commit` or `.pre-commit-config.yaml` rather than a per-clone hooks path it cannot observe. +- **The hook** runs **language formatting** and the **diff-scoped doc gates**, never Docker, so it stays fast. The formatting half is whatever the repo's own language needs, CSharpier and `dotnet format` for .NET or ruff and the repo's type checker for Python, via native tooling. A repo adds each half once its tree passes that half, since a gate that fails on the corpus it guards blocks every commit from the moment it lands, so a hook running one half is a repo mid-convergence rather than a repo out of conformance. The doc half runs each gate at the scope that fits it. The prose gate is scoped to the working tree diff against `HEAD` rather than swept over the tree, which is the difference between about 2.2 seconds and about 0.13 and is what makes it affordable in a hook at all. That scope is the working tree rather than the staged index under a plain git hook, so a partially staged file is judged on all of its edits. Under the `pre-commit` framework it is the staged state instead, since that runner stashes unstaged changes before running a hook, which is measured rather than assumed. CI re-checks the whole tree regardless, which is what makes the scope affordable in a hook either way. A whole-repo check belongs there too when it is already fast and takes no file list, which the line-ending consistency check is, so scope is a property of the gate rather than a rule the hook applies to all of them. `repo_gate.py --check sha-pin` stays out of the hook regardless, since it resolves a same-owner pin against the GitHub API. - **The doc gates reach a downstream repo by fetching `.github/actions/prose-gate/prose_lint.py` and `.github/actions/repo-gate/repo_gate.py` fresh from `ptr727/ProjectTemplate`'s `main` branch, via `hub-fetch-run.py`, never vendored and never pinned.** Pinning anything Dependabot does not maintain goes stale by construction, and CI (this repo's own, and the hub's) is the backstop that catches a change landing broken on `main` before a locally fetched copy does real damage. These are the only network calls the doc gates make, one per fetched script. A Python repo's `uvx`-run ruff and type checker can also reach the network, to resolve `@latest` on a cache miss or refresh, the same category of dependency as the Docker pulls the VS Code Lint tasks already do routinely, not a new one. A fetch failure fails the commit, and it never silently skips the gate. The **hub's own** `.husky/pre-commit` is the one exception, staying local and offline, since it already carries `scripts/prose_lint.py` and `scripts/repo_gate.py` directly and has no hub to reach. A repo enables its hook per clone with `git config core.hooksPath .husky` or (`uv tool install pre-commit` once, then) `pre-commit install`. The Husky.Net snippet needs one more step per clone, `dotnet tool restore` then `dotnet husky install`, which generates `.husky/_/husky.sh`, the file the hook sources. CI remains the authoritative run either way. - **The hub carries a second hook, `.husky/pre-push`, gating the local-review rule rather than lint.** It runs `scripts/local_review.py check` and `scripts/canonical_review.py check`, refusing a branch push whose diff no recorded review pass covers and one whose changed canonical units none covers, per "Verification Discipline" above. Both run before either verdict is read, so a blocked push names every reason it was refused rather than only the first, which would otherwise cost a second push to discover the next. It fires only for a branch update, so a tag push and a branch delete pass through, and a branch holding no net content against its target has nothing for a review to cover. It refuses rather than guesses in every state it cannot speak for, since the engine reads the checkout it runs in while a push delivers a commit, and those describe the same content only sometimes. The order that keeps them describing the same content is to run the carried-content pass and record it, commit that together with the change, then review the diff, record its receipt, and push. What the two gates actually require is narrower than that sequence, since the canonical ledger and burn-down are tracked and need only be committed before the push, while the diff receipt is not tracked and must be recorded after the last commit. The sequence above is the shortest one meeting both, which is why every capture point's wording assumes it. It reads `develop` and accepts no override from the environment, since an environment variable is set inline on the very command being gated, by whoever is being gated, which is the same reason an authorization is never read from a channel the agent itself can set. The cost is that it measures a branch based on anything but `develop` against `develop` anyway, so its refusal there is not a verdict about that branch. Each refusal names its own case, and the `local-strict-review` Skill carries the fleet's one enumeration of them with what clears each, deliberately in one place rather than restated here. It folds the engine's three-valued exit honestly: a check that could not run blocks too, and says so in different words than a check that ran and found no pass, because a gate that waves a push through when it could not run is a gate that stopped gating. It is a backstop rather than a seal, and it is bypassable by construction: `--no-verify` is the documented route out of a pickle and is not the only one, since a git hook cannot police its own invocation. A Claude Code session meets a narrower surface, that flag being denied unconditionally by the agent-safety hook's explicit-bypass rule, and that denial is Claude Code's alone, since Codex and opencode carry no such hook yet. So the committed hook raises the cost of skipping the rule for one agent and lowers it for none, and the prose layer above stays the agent-agnostic one that actually binds. It is hub-only for now. `local_review.py` is hub-hosted per "Hub-Hosted Tooling", and a downstream repo reaches it as a hub checkout's copy run with its own worktree as the working directory, so a catalog snippet carrying this hook fleet-wide is a later step rather than part of this one. `canonical_review.py` is hub-hosted too and is not reached that way at all: it reads `spec/files.json` to learn what is carried, `spec/` is hub-hosted rather than carried, and run against a downstream worktree it exits 2 saying that tree describes no carried set. It runs in the repository that authors the content, which is this one. The canonical-unit half is also run by the hub's own `.github/actions/validate` composite action, as a step on every pull request, which is where it actually binds, since a hook a push can bypass raises the cost of skipping the rule without ever settling it. That step is scoped to a pull request because a unit's change is measured against the branch it is proposed into, and a push carrying no pull request names none. - **The VS Code Lint tasks** run the full doc-lint set via Docker `:latest` on demand, the local surface for Markdown, spelling, workflow, and EditorConfig checks, plus `Lint: Prose` and `Lint: EOL`, the same two hook gates in whole-repo mode rather than diff-scoped, for on-demand full-tree validation. diff --git a/STANDUP.md b/STANDUP.md index 4617bd78..27e33f11 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -182,7 +182,7 @@ Carry `AGENTS.md`'s skill-dependency pointer paragraph, the one naming `scripts/ Choose the destination while scaffolding rather than after. Repo-specific content left in a carried file is drift, which the audit lists as an undeclared section to reconcile, and reconciling it later means moving prose that downstream readers have already started trusting in the wrong place. -**Wire a local commit hook here, not after the fact.** `.husky/pre-commit` and `.pre-commit-config.yaml` are deliberately excluded from the baseline above (each repo's own formatters make the content repo-owned, per [`spec/divergences.json`][divergences]), so nothing in the carry step above wires one. Copy and adapt the applicable catalog snippet, `catalog/snippets/husky/` for Husky.Net or `catalog/snippets/pre-commit/` for the Python `pre-commit` framework, each with `catalog/snippets/hub-fetch-run.py` alongside it, and enable it (`git config core.hooksPath .husky`, or `uv tool install pre-commit` once then `pre-commit install`) before the section 5 audit run. The Husky.Net snippet needs one more step per clone, `dotnet tool restore` then `dotnet husky install`, which generates `.husky/_/husky.sh`, the file the hook sources. A freshly stood-up repo with nothing wired starts pre-failed on `parity.hooks`. +**Wire a local commit hook here, not after the fact.** `.husky/pre-commit` and `.pre-commit-config.yaml` are deliberately excluded from the baseline above (each repo's own formatters make the content repo-owned, per [`spec/divergences.json`][divergences]), so nothing in the carry step above wires one. Copy and adapt the applicable catalog snippet with `catalog/snippets/hub-fetch-run.py` alongside it, then enable it, before the section 5 audit run. Which shape applies, what the gate must cover, and the per-clone enablement steps are all in [`GOVERNANCE.md`][governance] "Running the Linters Locally (Known-Working Invocations)", not restated here. A freshly stood-up repo with nothing wired starts pre-failed on `parity.hooks`. ## 3. Stand Up the Workflows diff --git a/WORKFLOW.md b/WORKFLOW.md index 3494f9ec..76ebc514 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -292,7 +292,7 @@ Each type maps the *applicable* S-scenarios onto its targets. The differences ar - **Docker image.** The leaf pushes the default branch multi-arch (amd64+arm64) and any other branch `amd64`-only, with a per-branch registry buildcache (`buildcache-`; a multi-image repo adds a per-image tag) (`cache-to` only the built branch and only on push, `cache-from` both branches); no `release-asset-*`, so a Docker-only repo's caller passes `expect_release_assets: false`; the readme job (`peter-evans/dockerhub-description`, `DOCKER_HUB_ACCESS_TOKEN`) runs **only** when the default branch publishes, whether called directly or reached through the hub-hosted `publish-docker-readme-task.yml`; the docker-readme task validates `repositories` XOR `manifest`+`manifest-jq` and a multi-image repo derives its publish matrix from the manifest. Docker **always re-pushes** the image, independently of a skipped release-create (S9). A **wrapper** repo tracks an upstream release: the upstream tracker writes a `name -> version` state file and the merge-bot auto-merges the bump PR (S11), and the leaf MUST read that file for the immutable tag instead of `SemVer2` (the tracker ships without this consumer wiring). Test: S7 default leg pushes `latest` + the version tag and updates the readme. Non-default pushes the develop tag (amd64 only). S9 still re-pushes. S11 ships the bumped upstream version next publish. 5C Docker probe needs `DOCKER_HUB_*` secrets and same-repo (not fork) runs. - **Data / asset library.** A single new leaf: validate -> zip -> upload `release-asset--library` (`retention-days: 1` per D5.4, upload gated `!smoke` per D1.3). Because no such leaf ships, you **add a target** (D6.4): a new `enable_library` input + `build-library` job + its `github-release` and `build-docker` `needs:` entries in the release task, and a `library` paths-filter entry + `changes` output + `smoke-build` enable-forward in the PR workflow (without it, D1.1 never smoke-builds the library). Keep `expect_release_assets: true` (it has a file target, unlike Docker). The .NET `unit-test` job is replaced by a type-appropriate validator, with the aggregator re-pointed to it (D1.2). `smoke-build` keeps `needs: [changes]`, as 5A's D1 line and the canonical stub both have it. `version.json` + the NBGV `get-version` step are retained (they own the tag). Test: S1 smoke runs validate+zip and uploads nothing; S7 attaches the zip, prerelease on the non-default leg; S9 on a *scheduled* re-run release-create + asset-delete skip (the existing zip is untouched, no registry push), while a `workflow_dispatch` re-run **refreshes** the release and re-runs the asset-delete (the asset is re-uploaded then re-deleted). N/A: the NuGet, PyPI, Docker, and .NET publish 5A addenda and their scenario clauses. - **Source-only / no build.** There is no package/image build leaf. A repo may own the reusable release task or call its hub-hosted copy. The dispatch-only `publish-release.yml` reaches the reusable plan, validation, and release tasks. Its publish job passes `github: true`, every `enable_*` input as false, and `expect_release_assets: false`. This produces tag + source zip + README + LICENSE with no asset download. With no target, the paths-filter matches nothing. A retained `smoke-build` job is therefore **structurally always skipped**. The repo may instead drop that never-running job. Validation remains the replaced, non-.NET validation job. The aggregator and any retained `smoke-build` job must depend on it (D1.2). NBGV and `version.json` own the tag. The publish job depends on the same reusable validation task that the PR workflow runs. This prevents a dispatch from releasing a ref that fails validation. Applicable scenarios are S1 (validation only), S7, S8, S9, and S10. S7 covers the tag-only release, S8 the dispatch guard, S9 no-op republish, and S10 the classification gate. S2-S6, D5/D6 artifact items, and all per-type 5A addenda are N/A. The artifact-lifecycle and registry clauses of S7/S9 are also N/A, not failed. -- **Static site deployed to a host the project owns.** Two independent surfaces, and keeping them apart is the point. The **release** is the source-only shape above, unchanged: a dispatch-only `publish-release.yml` where NBGV and `version.json` own the tag, producing tag + source zip + README + LICENSE. The **deploy** is its own `workflow_dispatch` carrying an `environment` choice input, so redeploying an unchanged commit mints no tag, which matters because redeploying is routine. It runs a ref gate **first**, before anything is installed or written (production from the default branch only, while any ref may reach a non-production environment, since proving a branch before it merges is what that environment is for), then the **same** reusable validation task the PR gate runs, so a dispatch cannot deploy a ref that fails validation, then calls the hub-hosted `deploy-site-task.yml`, with the `environment:` declared inside that task rather than on the calling job, since GitHub rejects a job carrying both `uses:` and `environment:`. The one crossing secret, `DEPLOY_SSH_PRIVATE_KEY`, is therefore mapped explicitly under the call's `secrets:`, because `secrets: inherit` does not carry an environment-scoped secret across a cross-repository call. Concurrency is keyed on the environment with `cancel-in-progress: false`, because a cancelled deploy leaves a release uploaded and unflipped. The task re-asserts the environment name in a job of its own, because the `environment:` binding resolves before any step runs and a `workflow_call` caller is not bound by the dispatch choice list a human sees. Its environment-bound job then: checks out full history (a shallow clone silently changes page metadata), derives the release id **once** and exports it (deriving it twice yields ids seconds apart, and the live check then asserts a version nothing installed), runs a required deploy hook that builds the tree with whatever generator and precompression the site owns, installs the deploy credential from the environment, uploads into a per-release directory hard-linked against the current release and carrying **no** delete flag (at an environment root a delete removes the rollback targets), flips the pointer as a separate atomic step so a failed transfer cannot half-publish, then runs the same hook again to prune old releases and to check the running host (D4.6). Retention (D5.6) is bounded by a declared count with one side recorded as owning it: a deploy whose credential can observe the destination prunes and asserts the count here, while a credential confined **write-only** can neither delete nor read back, so there the prune is a host-side timer and the repo's runbook records that ownership. Widening the credential to bring the prune in-pipeline would trade a real confinement boundary for a check, and is the wrong trade. What the guarantee rejects is neither side owning it. One thing the pipeline cannot assert and the server config must: a non-public environment serving a byte-identical copy must not be indexed, and that default belongs on the side that is harmless in production, since a non-public container missing the value is still behind its gate while a production container inheriting it deindexes the site silently. Applicable scenarios: S1 (validation), the source-only release set S7/S8/S9/S10, and S12/S13 (the deploy dispatch). N/A: S2-S4, every registry scenario, and D5.1-D5.4 (the pipeline uploads no workflow artifact at all, so D5.6 is what applies in their place), all recorded N/A, not failed. +- **Static site deployed to a host the project owns.** Two independent surfaces, and keeping them apart is the point. The **release** is the source-only shape above, unchanged: a dispatch-only `publish-release.yml` where NBGV and `version.json` own the tag, producing tag + source zip + README + LICENSE. The **deploy** is its own `workflow_dispatch` carrying an `environment` choice input, so redeploying an unchanged commit mints no tag, which matters because redeploying is routine. It runs a ref gate **first**, before anything is installed or written (production from the default branch only, while any ref may reach a non-production environment, since proving a branch before it merges is what that environment is for), then the **same** reusable validation task the PR gate runs, so a dispatch cannot deploy a ref that fails validation, then calls the hub-hosted `deploy-site-task.yml`, with the `environment:` declared inside that task rather than on the calling job, since GitHub rejects a job carrying both `uses:` and `environment:`. The crossing secrets, `DEPLOY_SSH_PRIVATE_KEY` and the optional `SITE_AUTH_TOKEN_ID`/`SITE_AUTH_TOKEN` pair the live check needs, are therefore mapped explicitly under the call's `secrets:`, the pair only where a token-gated live check needs it, since the task declares them and `secrets: inherit` is not used on a cross-repository call. What that task's own job reads for each of them comes from its `environment:` binding rather than from the caller's job context. Concurrency is keyed on the environment with `cancel-in-progress: false`, because a cancelled deploy leaves a release uploaded and unflipped. The task re-asserts the environment name in a job of its own, because the `environment:` binding resolves before any step runs and a `workflow_call` caller is not bound by the dispatch choice list a human sees. Its environment-bound job then: checks out full history (a shallow clone silently changes page metadata), derives the release id **once** and exports it (deriving it twice yields ids seconds apart, and the live check then asserts a version nothing installed), runs a required deploy hook that builds the tree with whatever generator and precompression the site owns, installs the deploy credential from the environment, uploads into a per-release directory hard-linked against the current release and carrying **no** delete flag (at an environment root a delete removes the rollback targets), flips the pointer as a separate atomic step so a failed transfer cannot half-publish, then runs the same hook again to prune old releases and to check the running host (D4.6). Retention (D5.6) is bounded by a declared count with one side recorded as owning it: a deploy whose credential can observe the destination prunes and asserts the count here, while a credential confined **write-only** can neither delete nor read back, so there the prune is a host-side timer and the repo's runbook records that ownership. Widening the credential to bring the prune in-pipeline would trade a real confinement boundary for a check, and is the wrong trade. What the guarantee rejects is neither side owning it. One thing the pipeline cannot assert and the server config must: a non-public environment serving a byte-identical copy must not be indexed, and that default belongs on the side that is harmless in production, since a non-public container missing the value is still behind its gate while a production container inheriting it deindexes the site silently. Applicable scenarios: S1 (validation), the source-only release set S7/S8/S9/S10, and S12/S13 (the deploy dispatch). N/A: S2-S4, every registry scenario, and D5.1-D5.4 (the pipeline uploads no workflow artifact at all, so D5.6 is what applies in their place), all recorded N/A, not failed. - **Operational (workflow model, not a build target).** A `workflowModel: operational` repo layers direct commits to `develop` onto the **source-only** release shape above. It has two workflows. The first is a **lint/validation** PR workflow that feeds the required `Check pull request workflow status job`. It uses the generic linters (editorconfig/EOL, markdownlint, cspell, actionlint) plus a domain validator, with **no unit tests**. Examples include Home Assistant `hass --script check_config`, `esphome config`, or a firmware build. Its triggers differ from the `release` model. It runs on pushes to `develop`, pull requests to `[ main, develop ]`, and `workflow_dispatch`. Push validation is advisory. Pull request validation is enforced on `main` and reported but not required on `develop`. The second workflow is the standard **source-only publisher** with `releaseTrigger: dispatch-only`. NBGV and `version.json` own the tag. The reusable release task creates tag + source zip + README + LICENSE. **The PR trigger names both branches, and naming `main` alone is a defect.** Omitting `develop` starts no validation when a PR opens against `develop`. The aggregator then never reports, and the PR appears clean with an empty check list. D1.2 forbids that output. Naming both causes a duplicate run after a PR merge. The change validates on the PR and again on the resulting push, regardless of merge method. The operational `develop` ruleset prescribes no merge method. The concurrency group uses the workflow name plus `${{ github.ref }}` (Section 2). A pull request uses `refs/pull//merge`, while its push uses `refs/heads/develop`. The runs occupy different groups and neither cancels the other. Pay that cost. The lint-only gate costs only a few runner-minutes. Suppressing the push requires distinguishing a merge commit from a direct commit, which restores the ambiguity the trigger set removes. S1 applies to every PR, including promotion and `develop` PRs. The source-only S7, S8, S9, and S10 scenarios also apply. Bot-push and schedule paths in S5/S6 are N/A, as are every build and registry scenario. See the branch-model note in Section 3 and [GOVERNANCE.md "Branching Model"][governance-branching-model]. diff --git a/catalog/README.md b/catalog/README.md index 8269cc2e..534b58fd 100644 --- a/catalog/README.md +++ b/catalog/README.md @@ -7,4 +7,4 @@ Reusable reference snippets: concrete config artifacts a repo can copy or compar - `snippets/configs/`: the config exemplars `vscode-tasks.json` (.NET clean-compile task group) and `vscode-tasks-python.json` (the Python equivalent, running `ruff`/type-check/`pytest`, all `type: process` so no `&&` chaining breaks Windows PowerShell 5.1), plus `dependabot.yml` (multi-ecosystem dual-target reference), `docker-hub-readme.md` (the size-limited Docker Hub overview, distinct from the project `README.md`). - `snippets/devcontainer/`: `.devcontainer` definitions for the .NET and Python toolchains. - `snippets/vscode/`: the composable `.code-workspace` fragments: `base.jsonc` (standard set) plus `dotnet.jsonc`, `python.jsonc`, `docker.jsonc` per-type additions. See `snippets/vscode/README.md`. -- `snippets/husky/` and `snippets/pre-commit/`: the two local commit-hook shapes, Husky.Net for .NET (or any project, ruff included) and the Python `pre-commit` framework, both carrying the same shared doc gates (prose/comment-style, line endings) via `snippets/hub-fetch-run.py`. See each directory's own README.md. +- `snippets/husky/` and `snippets/pre-commit/`: the two local commit-hook shapes, Husky.Net for a repo that already keeps a .NET tool manifest declaring it and the `pre-commit` framework for a repo without one, both carrying any language's checks plus the same shared doc gates (prose/comment-style, line endings) via `snippets/hub-fetch-run.py`. A repo may instead wire an equivalent hook of its own at `.husky/pre-commit`, sourcing nothing and enabled with `core.hooksPath`, which is what this repository's own `.husky/` does. See each directory's own README.md. diff --git a/catalog/snippets/husky/README.md b/catalog/snippets/husky/README.md index 6f347f3d..854e3805 100644 --- a/catalog/snippets/husky/README.md +++ b/catalog/snippets/husky/README.md @@ -2,7 +2,7 @@ `pre-commit` is the reference git pre-commit hook (installed under `.husky/` by Husky). It runs **language formatting/lint and the fleet's shared doc gates**: CSharpier and -`dotnet format` style via `dotnet husky run` for .NET, or `ruff format --check` / `ruff check` / +`dotnet format` style via `dotnet husky run` for .NET, plus `ruff format --check` / `ruff check` / the repo's type checker for a Python repo (native tooling, no Docker, the same checks the `../pre-commit/.pre-commit-config.yaml` snippet runs), plus the diff-scoped prose/comment-style gate and the whole-tree line-ending check. Copy `../hub-fetch-run.py` alongside `pre-commit` for @@ -13,6 +13,12 @@ change that lands broken on `main` before it does real damage locally. These are network fetches alongside the Docker pulls the Lint tasks below already do. A fetch failure fails the commit rather than silently skipping the gate. +This snippet is for a repo that already keeps a .NET tool manifest declaring Husky.Net, since +the hook sources `.husky/_/husky.sh`, a file `dotnet husky install` generates after +`dotnet tool restore` restores that manifest. This snippet ships no manifest, so a host .NET +toolchain alone is not enough and a repo without one uses `../pre-commit/` instead. Neither snippet is scoped to a language by +what it runs, and the two shared doc gates are what every repo owes either way. + Full linting (workflow YAML, Markdown, spelling, EditorConfig) is **not** run in the hook. It runs in CI as pinned action wrappers, and on demand via the VS Code **Lint** tasks in `catalog/snippets/configs/vscode-tasks.json` (Docker at `:latest`), which also carries the @@ -29,5 +35,5 @@ override needed. A CRLF shebang breaks execution. Both language blocks run unconditionally, with no tool-presence guard: a repo that keeps a block declares that tool required, so a missing one fails the commit loudly rather than -skipping the check silently. Drop the `dotnet husky run` block in a non-.NET repo, and drop -the Python block in a non-Python repo, rather than leaving it in place to no-op. +skipping the check silently. Drop the `dotnet husky run` block in a repo with no .NET project, +and the Python block in a repo with no Python, rather than leaving either in place to no-op. diff --git a/catalog/snippets/husky/pre-commit b/catalog/snippets/husky/pre-commit index ba07fc01..26a7984d 100644 --- a/catalog/snippets/husky/pre-commit +++ b/catalog/snippets/husky/pre-commit @@ -27,14 +27,14 @@ uv run ruff check || exit 1 # Swap for `uv run mypy` if this repo's CI runs mypy instead. uv run pyright || exit 1 -# The interpreter is chosen by probing, not assumed, mirroring spec/host-tools.json's own probe order. -# On native Windows the python.org install registers `py`, not `python3`, and a `python3` found on PATH there is often the Microsoft Store alias stub, present but broken. -if python3 --version >/dev/null 2>&1; then - run_py() { python3 "$@"; } -elif py -3 --version >/dev/null 2>&1; then +# The interpreter is chosen by running the probes spec/host-tools.json declares, in its order. +# Running one rather than testing for a name is the point, since Git Bash inherits a Windows PATH that can carry a name which fails when run. +if py -3 --version >/dev/null 2>&1; then run_py() { py -3 "$@"; } +elif python3 --version >/dev/null 2>&1; then + run_py() { python3 "$@"; } else - echo "pre-commit: neither 'python3 --version' nor 'py -3 --version' ran, so the doc gates did not run." >&2 + echo "pre-commit: neither 'py -3 --version' nor 'python3 --version' ran, so the doc gates did not run." >&2 exit 1 fi diff --git a/catalog/snippets/pre-commit/.pre-commit-config.yaml b/catalog/snippets/pre-commit/.pre-commit-config.yaml index 2e267ea4..9fbda874 100644 --- a/catalog/snippets/pre-commit/.pre-commit-config.yaml +++ b/catalog/snippets/pre-commit/.pre-commit-config.yaml @@ -1,4 +1,6 @@ -# Reference pre-commit (pre-commit.com) config for a fleet Python repo with no .NET/Husky.Net tree. +# Reference pre-commit (pre-commit.com) config for a fleet repo that keeps no .NET tool manifest declaring Husky.Net, or one that prefers this runner. +# Husky.Net is a dotnet tool, so the discriminator is the toolchain rather than the repo's languages. +# Drop the ruff and type-check hooks in a repo with no Python, keeping the two doc gates every repo owes. # Copy into the repo root alongside ../hub-fetch-run.py. # Adapt the type-check hook to match this repo's own CI checker (pyright or mypy, per python-codestyle). # Install and enable with `uv tool install pre-commit` once, then `pre-commit install`. diff --git a/catalog/snippets/pre-commit/README.md b/catalog/snippets/pre-commit/README.md index 771d7c70..6241c0bf 100644 --- a/catalog/snippets/pre-commit/README.md +++ b/catalog/snippets/pre-commit/README.md @@ -1,14 +1,23 @@ # Pre-commit snippet -`.pre-commit-config.yaml` is the reference config for the Python `pre-commit` framework -(pre-commit.com), for a Python repo with no `.husky/` tree of its own. It runs `ruff format ---check` and `ruff check`. It also runs this repo's declared type checker, `pyright` or -`mypy`, matching whichever `python-codestyle` says this repo's CI runs. Each tool runs via -`uvx`, native tooling, never Docker. `uvx` needs no project dependency, matching CI's own +`.pre-commit-config.yaml` is the reference config for the `pre-commit` framework +(pre-commit.com), for a repo that keeps no .NET tool manifest declaring Husky.Net, and for any +repo that prefers it to Husky.Net. The toolchain the repo already keeps is the discriminator, +not the repo's own languages: Husky.Net runs from a .NET tool manifest declaring it, and this +framework needs none. +In a repo with Python, it runs `ruff format --check` and `ruff check`. It also runs that repo's +declared type checker, `pyright` or `mypy`, matching whichever `python-codestyle` says its CI +runs. Each tool runs via `uvx`, native tooling, never Docker. `uvx` needs no project dependency, matching CI's own invocation for the lint-only profile (`CODESTYLE.md` "Two profiles"). A repo on the build profile with a `uv.lock` may swap in `uv run ` per entry to pin the project's own version instead. The config also runs the same two shared doc gates the Husky.Net snippet -carries: the diff-scoped prose/comment-style gate and the whole-tree line-ending check. +carries: the diff-scoped prose/comment-style gate and the whole-tree line-ending check. Those two +are what every repo owes regardless of language. The prose gate's `--diff HEAD` sees the staged +state under this runner rather than the working tree, since `pre-commit` stashes unstaged changes +before it runs a hook, so a partially staged file is judged on its staged edits alone here where +the Husky.Net shape judges all of them. CI re-checks the whole tree either way. A repo with no Python drops the `ruff-format`, +`ruff-check` and `type-check` hooks and keeps the doc gates, which is what a Docker, config, or +docs repo wires. Copy `../hub-fetch-run.py` alongside `.pre-commit-config.yaml` (repo root) for the doc gates to run: it fetches those two checks fresh from `ptr727/ProjectTemplate`'s `main` branch and @@ -27,9 +36,11 @@ install`'s own bin directory is not yet on `PATH`: run `uv tool update-shell` an re-source the shell, or add the directory `uv tool dir --bin` prints directly. Full linting (workflow YAML, Markdown, spelling, EditorConfig) stays out of the hook: it runs in CI as pinned action wrappers, and on -demand via the VS Code **Lint** tasks in `catalog/snippets/configs/vscode-tasks-python.json` -(Docker at `:latest`), which also carries the same prose/EOL gates in whole-repo mode for -on-demand full-tree validation, not just the diff-scoped commit-time run. +demand via the VS Code **Lint** tasks. A Python repo takes those from +`catalog/snippets/configs/vscode-tasks-python.json` (Docker at `:latest`), and a repo with no +Python takes the Lint group alone rather than that file's Python tasks. Either way they carry +the same prose/EOL gates in whole-repo mode for on-demand full-tree validation, not just the +diff-scoped commit-time run. No LF pin is needed for `.pre-commit-config.yaml` itself: it is plain YAML, not a shebang script, so the fleet's `[*]` `.editorconfig`/`.gitattributes` default already covers it. diff --git a/docs/pre-commit-hooks-rollout.md b/docs/pre-commit-hooks-rollout.md index d770d958..eb1e4e3f 100644 --- a/docs/pre-commit-hooks-rollout.md +++ b/docs/pre-commit-hooks-rollout.md @@ -25,8 +25,9 @@ defect, the same severity a missing markdownlint config already gets. A repo mid doc-gate half wired and the language-format half not yet because its own corpus does not pass it clean, stays operational, per the existing carve-out in GOVERNANCE.md. Two catalog snippets carry the canonical shape: Husky.Net -(`catalog/snippets/husky/`, for .NET or any project including Python) and the Python `pre-commit` -framework (`catalog/snippets/pre-commit/`, for a repo with no `.husky/` tree). Both now carry the +(`catalog/snippets/husky/`, for a repo already keeping a .NET tool manifest declaring it) and the +`pre-commit` framework (`catalog/snippets/pre-commit/`, for a repo without one and for any repo +that prefers it). Both now carry the same shared doc gates, the diff-scoped prose/comment-style gate and the whole-tree line-ending check, fetched fresh from the hub's `main` branch at run time via `hub-fetch-run.py` rather than vendored or pinned. diff --git a/docs/reusable-workflows.md b/docs/reusable-workflows.md index cfce8983..9f423dd1 100644 --- a/docs/reusable-workflows.md +++ b/docs/reusable-workflows.md @@ -68,7 +68,7 @@ The sequencing consequence is that a hub task lands on `develop`, promotes to `m ### Secrets and Permissions -Every hub task declares the secrets it needs by name under `on.workflow_call.secrets`, and a caller maps each one explicitly. Most are `required: true`. A mechanism's secret is `required: false` where the task treats it as one of several opt-in targets, such as `DOCKER_HUB_USERNAME`/`DOCKER_HUB_ACCESS_TOKEN` in `build-release-task.yml`. A package-registry credential is not among them, since `NUGET_USERNAME` and the PyPI OIDC exchange are read by the caller stub's own `publish-nuget` / `publish-pypi` job rather than passed into the task, per [Adopting the Release Chain][adopting-the-release-chain]. The same names are `required: true` in a task built around that one mechanism instead, such as `DOCKER_HUB_USERNAME`/`DOCKER_HUB_ACCESS_TOKEN` in `build-docker-task.yml`. `secrets: inherit` is not used, since it is documented for a caller in the same organization or enterprise as the called workflow and the fleet is a personal account. The declared names are the ones [`spec/secrets.json`][secrets] already declares for the mechanism the task implements, so the secret audit and the workflow agree by construction. An environment-scoped secret is the exception. `DEPLOY_SSH_PRIVATE_KEY` and the `SITE_AUTH_TOKEN_ID`/`SITE_AUTH_TOKEN` pair beside it cross a GitHub Environment boundary `spec/secrets.json` has no vocabulary for, per its `deploy-ssh` mechanism note. +Every hub task declares the secrets it needs by name under `on.workflow_call.secrets`, and a caller maps each one explicitly. Most are `required: true`. A mechanism's secret is `required: false` where the task treats it as one of several opt-in targets, such as `DOCKER_HUB_USERNAME`/`DOCKER_HUB_ACCESS_TOKEN` in `build-release-task.yml`. A package-registry credential is not among them, since `NUGET_USERNAME` and the PyPI OIDC exchange are read by the caller stub's own `publish-nuget` / `publish-pypi` job rather than passed into the task, per [Adopting the Release Chain][adopting-the-release-chain]. The same names are `required: true` in a task built around that one mechanism instead, such as `DOCKER_HUB_USERNAME`/`DOCKER_HUB_ACCESS_TOKEN` in `build-docker-task.yml`. Whether `secrets: inherit` is used is decided by the call's own boundary, not by the fleet's preference. [GitHub documents the keyword][gh-reusing-workflows] for a caller in the same organization or enterprise as the called workflow, and the fleet is a personal account. So a cross-repository call to a hub task names each secret it passes, and `inherit` is never used on one. A call whose job needs none passes no `secrets:` key, which is what the [Adopting the Gates][adopting-the-gates] `validate` stub does. A call by local path stays inside one repository. There the caller's own secret store is the one the called workflow reads, so `inherit` is available. Availability is not a reason to use it, and this repository's own local-path calls name their secrets or pass none. Both shapes sit side by side in PlexCleaner's `test-pull-request.yml`. Its `validate` job calls `./.github/workflows/validate-task.yml` with `secrets: inherit`, to thread `CODECOV_TOKEN` through. Its `smoke-build` job names `DOCKER_HUB_USERNAME` and `DOCKER_HUB_ACCESS_TOKEN` explicitly for the hub's `build-release-task.yml`. The [Adopting the Gates][adopting-the-gates] smoke-build stub carries the local-path shape for the same reason. NxWitness proves an inherited value actually arrives. Its `publish-release.yml` calls its own `build-docker-task.yml` with `secrets: inherit` and `push: true`. That task authenticates to Docker Hub from the two inherited names. The declared names are the ones [`spec/secrets.json`][secrets] already declares for the mechanism the task implements, so the secret audit and the workflow agree by construction. An environment-scoped secret is the exception. `DEPLOY_SSH_PRIVATE_KEY` and the `SITE_AUTH_TOKEN_ID`/`SITE_AUTH_TOKEN` pair beside it cross a GitHub Environment boundary `spec/secrets.json` has no vocabulary for, per its `deploy-ssh` mechanism note. A hub task declares no job-level `permissions:` where every write goes through the App token, and the caller sets `permissions: {}`. A called workflow can only keep or reduce the caller's grant. A callee job naming a scope the caller did not grant fails at startup even when its `if:` is false. Declaring nothing in the callee is therefore the shape that cannot fail against any caller, and it gives `GITHUB_TOKEN` no scope. A task whose job genuinely writes with `GITHUB_TOKEN`, such as a release upload, declares that scope in the callee job and documents it in the stub's comment so the caller grants it. @@ -611,7 +611,7 @@ The caller grants `contents: read` explicitly, since the task's own jobs declare ESPHome-NonRoot carries two trackers today. `check-upstream-version.yml` adopts the stub above as-is. `check-upstream-dependency.yml`, whose bump waits for a human because its head deliberately does not match a merge-bot rule, adopts a second instance of the same stub with `with: { branches: '["develop"]', bump-branch-prefix: upstream-dependency, auto-merge: false }` and a `resolve-upstream` hook shaped around its apt-package snapshot, setting `versions` to `{"docker_base_packages": ""}` rather than a name-to-version map. The generic title and body this produces read less specifically than today's bespoke "packages added/removed" wording, which is the cost of folding a bespoke tracker into the shared task. -**Deploy-site.** A site repo keeps `deploy-site.yml` as a per-repo caller (it has no manifest-wide catalog snippet either, since its `uses:` now names the hub, and it still carries the dispatch, the ref gate, and the shared validation call), but its `deploy` job reaches the hub-hosted `deploy-site-task.yml`. A job calling a reusable workflow cannot itself carry an `environment:` key ([GitHub's supported-keywords list][gh-reusing-workflows] omits it, and GitHub rejects the combination outright), so the caller's `deploy` job carries none. `DEPLOY_SSH_PRIVATE_KEY` still crosses correctly: the task's own `deploy` job binds `environment: ${{ inputs.environment }}` on itself, and per GitHub's own cross-repository behavior (the `github` context, and an OIDC token's `sub` claim, always attribute the environment to the *caller*), that resolves against the caller's own GitHub Environment store, not the hub's. The caller's `secrets: DEPLOY_SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}` line is still required (the task declares this a required `workflow_call.secrets` input, and GitHub rejects a call omitting a required one), but what value the task's own job actually sees for it is governed by the task's own `environment:` binding, not by anything the caller's own (environment-less) job context could resolve. Confirmed both against GitHub's documented reusable-workflow secrets mechanics and with a live cross-repository run against Blog's own `staging` environment ([issue #942][issue-942]). `secrets: inherit` was never the alternative here regardless, since it does not carry environment-scoped secrets across a cross-repository call. A required `deploy` hook, `.github/actions/deploy/action.yml`, is invoked three times (`build`, `prune`, `verify`) so the site keeps its own generator, precompression, and URL contract while the upload-then-flip sequence stays hub-owned. The hook declares all four inputs the three invocations use between them, `mode`, `bundle-path`, `release-id`, and `environment`, since a composite action rejects an invocation that supplies an input it does not declare, even one a different mode leaves unset. Each invocation also passes the GitHub Environment variables that mode needs (`SITE_BASE_URL` to `build` and `verify`, `DEPLOY_SSH_USER` and `DEPLOY_SSH_HOST` to `prune`) as plain `env:` vars, since a composite action's own steps are not guaranteed to read the caller's `vars` context directly. `verify` additionally receives an optional `SITE_AUTH_TOKEN_ID`/`SITE_AUTH_TOKEN` secret pair the same way, forwarded whenever the caller maps it. A site whose live check sits behind its own token-gated auth is the reason it exists. +**Deploy-site.** A site repo keeps `deploy-site.yml` as a per-repo caller (it has no manifest-wide catalog snippet either, since its `uses:` now names the hub, and it still carries the dispatch, the ref gate, and the shared validation call), but its `deploy` job reaches the hub-hosted `deploy-site-task.yml`. A job calling a reusable workflow cannot itself carry an `environment:` key ([GitHub's supported-keywords list][gh-reusing-workflows] omits it, and GitHub rejects the combination outright), so the caller's `deploy` job carries none. `DEPLOY_SSH_PRIVATE_KEY` still crosses correctly: the task's own `deploy` job binds `environment: ${{ inputs.environment }}` on itself, and per GitHub's own cross-repository behavior (the `github` context, and an OIDC token's `sub` claim, always attribute the environment to the *caller*), that resolves against the caller's own GitHub Environment store, not the hub's. The caller's `secrets: DEPLOY_SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}` line is still required (the task declares this a required `workflow_call.secrets` input, and GitHub rejects a call omitting a required one), but what value the task's own job actually sees for it is governed by the task's own `environment:` binding, not by anything the caller's own (environment-less) job context could resolve. Confirmed both against GitHub's documented reusable-workflow secrets mechanics and with a live cross-repository run against Blog's own `staging` environment ([issue #942][issue-942]). `secrets: inherit` was never the alternative here regardless. It is not used on a cross-repository call at all, per [Secrets and Permissions][secrets-and-permissions], and it would not carry an environment-scoped secret across one either. A required `deploy` hook, `.github/actions/deploy/action.yml`, is invoked three times (`build`, `prune`, `verify`) so the site keeps its own generator, precompression, and URL contract while the upload-then-flip sequence stays hub-owned. The hook declares all four inputs the three invocations use between them, `mode`, `bundle-path`, `release-id`, and `environment`, since a composite action rejects an invocation that supplies an input it does not declare, even one a different mode leaves unset. Each invocation also passes the GitHub Environment variables that mode needs (`SITE_BASE_URL` to `build` and `verify`, `DEPLOY_SSH_USER` and `DEPLOY_SSH_HOST` to `prune`) as plain `env:` vars, since a composite action's own steps are not guaranteed to read the caller's `vars` context directly. `verify` additionally receives an optional `SITE_AUTH_TOKEN_ID`/`SITE_AUTH_TOKEN` secret pair the same way, forwarded whenever the caller maps it. A site whose live check sits behind its own token-gated auth is the reason it exists. Blog is the reference adoption, and its real inventory is two scripts, not three: `deploy/make-release.sh` assembles, hard-links, stamps, and installs a release into whatever root it is pointed at, and `checks/check-live-urls.sh` verifies one against a running server. There is no `deploy/prune-releases.sh`. `build` mode wraps `make-release.sh` pointed at the hub-passed `bundle-path` rather than a live root, alongside whatever generator setup the hook itself needs, Hugo and brotli in Blog's case, that `make-release.sh` assumes are already on `PATH`. The script's own tail, a swap of a local `current` symlink to the release it just wrote and a check that the swap hard-linked something against whatever `current` pointed at before, runs entirely against that ephemeral `bundle-path`, so it is local bookkeeping rather than a second real deploy. It is also what leaves `bundle-path/current` in place for the hub task's own build-mode assertion to find. Because `bundle-path` is empty at the start of every run, that local `current` never resolves to anything and the hard-link check never has a previous release to compare against, so it is inert in CI. The `build` hook's own `current` is never the live one either way: only the hub-owned Upload release and Flip current steps that follow touch the real `//` root, so the boundary the upload-then-flip sequence draws is between `bundle-path` and the environment, not a seam inside `make-release.sh` itself. @@ -667,6 +667,7 @@ Four things the hub cannot prove fall to the first downstream adopter. They are [open-decisions]: #open-decisions [pinning]: #pinning [rollout]: #rollout +[secrets-and-permissions]: #secrets-and-permissions [stage-4]: #stage-4-the-release-chain-and-the-docker-core [the-docker-family]: #the-docker-family diff --git a/reports/canonical-review.json b/reports/canonical-review.json index cb008295..68d04040 100644 --- a/reports/canonical-review.json +++ b/reports/canonical-review.json @@ -9,6 +9,14 @@ "hubCommit": "0e6aacf45334149f6158aa23b8132d6aee1d1eb0", "stamp": "2026-09-01T16:37:57Z" }, + { + "unit": ".agents/skills/agent-conduct/SKILL.md > When a Failure Surfaces a Lesson", + "digest": "sha256:7bb4825e38c44c74366d2f0051d521968d87df765144bb080ca059a417e2cd75", + "reviewer": "agent-skill", + "findings": 6, + "hubCommit": "52b0c550ab07ab58b940a353a83247d7451e76e5", + "stamp": "2026-09-02T01:11:56Z" + }, { "unit": ".agents/skills/comment-and-doc-style/SKILL.md > Docker lint authorization", "digest": "sha256:b61bea3f7010cafc475ce582e234e79f1e55e4bd0e5332dbe2b05f4fb5bb03ff", @@ -27,19 +35,19 @@ }, { "unit": ".agents/skills/dotnet-codestyle/SKILL.md > Build requirements", - "digest": "sha256:c9a8e764ae6c8423420ba2cb7e8b65d5a0328a405be16a798b8aec521f01c459", + "digest": "sha256:96e6651c2e024fae3bca713f4a8cab34b37a9bb9eb693a3e25de04465b03113c", "reviewer": "agent-skill", "findings": 0, - "hubCommit": "ed329f4029c9b035662a96f00834152c27bf2d8b", - "stamp": "2026-09-01T06:18:30Z" + "hubCommit": "a2c9dab3bfde574d179b0765f9bb50f413ce7a80", + "stamp": "2026-09-02T03:33:05Z" }, { "unit": ".agents/skills/dotnet-codestyle/SKILL.md > Tooling and editor", - "digest": "sha256:d00e3da0fde14b915ee959bcab0edb68d21cd0b22d954054058bfea21e7473f5", + "digest": "sha256:bee41f58a11d3ee23789d5839a26798dba19c89d1e500ec468c918f55a41cf40", "reviewer": "agent-skill", - "findings": 0, - "hubCommit": "a76dda7d67033f43c260d21e3ae6a291e1869c0a", - "stamp": "2026-09-01T06:46:39Z" + "findings": 1, + "hubCommit": "a2c9dab3bfde574d179b0765f9bb50f413ce7a80", + "stamp": "2026-09-02T03:33:05Z" }, { "unit": ".agents/skills/drive-pr/SKILL.md > Disposing of Every Finding", @@ -163,11 +171,11 @@ }, { "unit": ".agents/skills/python-codestyle/SKILL.md > Local development loop", - "digest": "sha256:6f1081d1c1cf0f719f9c154c0b5652a26c7c1f6d2478adc3717becb248a61458", + "digest": "sha256:64911d87dea3c5c46bbe2abccab0bed590ee65a3e93e9083b403ceee77bde450", "reviewer": "agent-skill", "findings": 1, - "hubCommit": "5664a954bf0c11acb6bab9014b0463697d18da4b", - "stamp": "2026-09-01T23:19:53Z" + "hubCommit": "5eb14540421d630293807288845284a78f8ecc7d", + "stamp": "2026-09-02T03:51:13Z" }, { "unit": ".agents/skills/repo-worktree/SKILL.md > Creating a Worktree", @@ -273,6 +281,14 @@ "hubCommit": "37d116a2fa0cecf85a220c9005375091db240a8d", "stamp": "2026-09-01T14:23:11Z" }, + { + "unit": ".github/copilot-instructions.md > Reviewing Carried Fleet Content", + "digest": "sha256:83570e2b15143c6f9b79e4d381b7a578d3ae7970d74ae676405a402f6d13630f", + "reviewer": "agent-skill", + "findings": 2, + "hubCommit": "f0ff674ec0d59bb2c9ec3736ef22d9de205059c8", + "stamp": "2026-09-02T03:21:55Z" + }, { "unit": "AGENTS.md > Where the Rules Live", "digest": "sha256:2526a341afe0c6f1e2d2e1fe954855398742474a816e2bcba640672d1363766f", @@ -299,11 +315,11 @@ }, { "unit": "CODESTYLE.md > General", - "digest": "sha256:ee34e84dab97a638e5011f7b8b5fc30893f82461cf6907ba35c96030eb2d4bb0", + "digest": "sha256:54fcc07fc743507089e95ab8f1e7aeb64f0d83e31716514dadb666871a495684", "reviewer": "agent-skill", - "findings": 10, - "hubCommit": "df5e2493b48aa45d80f586db68af0c21be97ea21", - "stamp": "2026-09-01T05:58:03Z" + "findings": 1, + "hubCommit": "112ffd874eaec3784a747b341fbdd0b19d75502c", + "stamp": "2026-09-02T03:09:17Z" }, { "unit": "CODESTYLE.md > Shell", @@ -315,11 +331,11 @@ }, { "unit": "GOVERNANCE.md > Durable Knowledge and Self-Improvement", - "digest": "sha256:d2caaf6052fdf9838034bcd3dbf8a250d44b303b3bb73754ecb4c6fa0ead8bd0", + "digest": "sha256:cd5ef7be0c674a9ba29c3196ae81757ae18f7bb8ca82f573ffd77f96d363cc8e", "reviewer": "agent-skill", - "findings": 2, - "hubCommit": "d0541523890324cbbdbb63631c8dd83e6003703a", - "stamp": "2026-09-01T19:25:11Z" + "findings": 11, + "hubCommit": "52b0c550ab07ab58b940a353a83247d7451e76e5", + "stamp": "2026-09-02T01:11:58Z" }, { "unit": "GOVERNANCE.md > Repository Layout", @@ -379,11 +395,11 @@ }, { "unit": "WORKFLOW.md > 6. Per-Project-Type Test Walkthroughs", - "digest": "sha256:f6b2044c5b5de3aafd760cf001797e807749c391af93ca2ec0cb3d436b884a33", + "digest": "sha256:90cd1e08e32954143484bbd75d2e90fd6b59e70141eac0bcd4e20a5d662335ac", "reviewer": "agent-skill", - "findings": 0, - "hubCommit": "c3b2898feef97459d01ce7c0b63de25b6c5524bf", - "stamp": "2026-09-01T13:57:21Z" + "findings": 1, + "hubCommit": "d4cb996ed67bed9e327c5a3b3c4b441466bc37bb", + "stamp": "2026-09-02T02:28:24Z" } ] } diff --git a/reports/canonical-review.md b/reports/canonical-review.md index eee8949f..f8fe568a 100644 --- a/reports/canonical-review.md +++ b/reports/canonical-review.md @@ -7,9 +7,9 @@ A unit is what a reviewer reads whole, decided by the carry manifest rather than ## Coverage - units: 288 -- covered: 48 +- covered: 50 - stale: 0 -- never read here: 240 +- never read here: 238 ## Burn-down @@ -26,7 +26,6 @@ A unit is what a reviewer reads whole, decided by the carry manifest rather than - **(preamble)** - never - **Before Assuming** - never - **Delegation, in One Paragraph** - never -- **When a Failure Surfaces a Lesson** - never - **Why This Exists** - never ### .agents/skills/audit-a-repo/SKILL.md @@ -336,7 +335,6 @@ A unit is what a reviewer reads whole, decided by the carry manifest rather than - **(preamble)** - never - **Commit Messages and Pull Request Titles** - never - **GitHub Copilot Review Runbook** - never -- **Reviewing Carried Fleet Content** - never - **When in Doubt** - never ### .markdownlint-cli2.jsonc diff --git a/spec/audit.py b/spec/audit.py index 34f1d064..448bf748 100755 --- a/spec/audit.py +++ b/spec/audit.py @@ -2453,7 +2453,7 @@ def audit_repo(entry, spec, branch=None): ) ) # The undeclared-section advisory, per spec/section-model.md, treats an H2 the manifest does not declare as a candidate duplicate of a verbatim section, or as repo-specific content to relocate. - # It is advisory only, since a repo may legitimately carry its own project-specific sections, which the AGENTS.md preamble allows, so it points at the reconciliation and never fails. + # It is advisory only, since the reconciliation can end in promoting the rule here or relocating it, so it points at that choice and never fails. # It covers UNDECLARED_HEADING_SCANNED, not only AGENTS.md and GOVERNANCE.md, and never names which destination file an undeclared heading belongs in. # Skip the hub itself, since its copies are the source and legitimately hold hub-only sections, Repository Onboarding and Conformance being one, that are deliberately not carried. # A downstream repo carrying such a section is still flagged, which is the point. @@ -2981,8 +2981,9 @@ def _selftest(): 1, ), ] - # The deploy-site.yml caller stub once deploy-site-task.yml is hub-hosted: no secrets: inherit - # (a cross-repository reusable workflow cannot use it), the one crossing secret named instead. + # The deploy-site.yml caller stub once deploy-site-task.yml is hub-hosted: no secrets: inherit, the required crossing secret named instead. + # The task also declares an optional SITE_AUTH_TOKEN_ID/SITE_AUTH_TOKEN pair, which a caller maps only where its live check needs one. + # inherit is documented for same-org or enterprise callers, so the fleet does not use it across repositories. # No job-level environment: on the caller, unsupported on a job with uses: (ptr727/ProjectTemplate#942). deploy_stub = ( "jobs:\n" diff --git a/spec/fidelity-model.md b/spec/fidelity-model.md index 8d514b5f..7407c733 100644 --- a/spec/fidelity-model.md +++ b/spec/fidelity-model.md @@ -32,6 +32,19 @@ The fixed interface of a workflow is stated in [`WORKFLOW.md`][workflow] ("Two L Once a workflow's job graph moves into the hub, the carried unit is a caller stub rather than the job graph itself, and the contract moves with it: `publish-release.yml`'s `interface` check asserts the `plan`, `validate`, and `publish` job keys and that `plan` and `publish` each name their hub task by token (`publish-plan-task.yml`, `build-release-task.yml`), never the build job list a carried `build-release-task.yml` used to expose. +## What a Local Edit Looks Like + +A downstream repo editing carried content is a different question from a copy trailing the hub, and the answer differs by tier. [`GOVERNANCE.md`][governance] "Durable Knowledge and Self-Improvement" sends a change to carried rule text upstream rather than into the local copy. What follows is why a local patch there is so easy to miss. + +The unit decides, never the file. `GOVERNANCE.md` is `intent` as a file while 18 of its 20 declared sections are `verbatim`. An edit inside one of those is caught, and an edit to the prose around them is not. + +- **verbatim and verbatim-tree** - detected. The content hash is compared against the canonical and every past revision, and "Stale Versus Modified" below reads the result. An edit matching no revision reports as modified fixed content, and one restoring an earlier revision reports as stale, since the hash cannot say how the bytes got there. A `verbatim-tree` declaration also fails structurally, on a missing included path or an extra one under a pruned root, before any hash is read. +- **interface** - detected only where the edit moves the contract. A changed job key, check name, or hub task token is a finding. An edit to the owned body is the override this tier exists for. +- **intent** - not detected. Content is never judged, and the staleness advisory never identifies a local edit. It compares last-change dates, so the edit first suppresses the advisory by making the copy newer. After the hub's next substantive change to that file, meaning one the line-ending, action-pin, and job-needs normalization does not explain away, the advisory fires and reports the copy as trailing, which routes to a re-vendor that overwrites the edit rather than to the edit itself. +- **presence** - a content edit is not detected. The unit is asserted to exist and is never read, so deleting it is still a finding and editing it is not. + +So a local rule edit to `CODESTYLE.md` or `WORKFLOW.md`, both `intent` and whole, is reported nowhere. That silence is intended for a repo's own additions, which `CODESTYLE.md` is a declared destination for, and it is the hazard for a patch of carried rule text, since the two read alike to every check. Two checks reach some non-verbatim prose, and neither reaches those two files. [`section-model.md`][section-model]'s undeclared-section advisory names a heading the hub does not declare, and it runs only against `AGENTS.md`, `GOVERNANCE.md` and `.github/copilot-instructions.md`. It reads headings rather than rule text, so a rule rewritten under a declared heading stays invisible. [`audit.py`][audit]'s `template_ref_outside_verbatim` reads the prose itself, and it catches only a coordination reference rather than a changed rule. + ## Normalization A verbatim check compares content by hash after **line-ending, action-pin, and job-needs normalization**. EOL variance is governed by the line-ending rules, a `uses: @` pin (with its trailing `# vN` comment) is Dependabot-owned and bumped per repo, and a job's `needs:` list is pruned per repo to its vendored targets (an unvendored name fails the workflow to load), so all three are governed drift rather than a fidelity deviation. This keeps a verbatim workflow job region (the `github-release` job) from flagging on a routine action bump or a legitimate needs-prune while still catching a real structural fork. It does **not** mask placeholders: a verbatim unit carries none. The files that declare a `placeholders` list (for example `.github/copilot-instructions.md` with ``, ``, ``) are fidelity `intent`, judged by hand and never hashed. Masking could not serve a hash anyway. A downstream copy holds the substituted value (`ptr727`), not the token (``), so masking the token in the canonical alone would guarantee a mismatch. A verbatim unit that ever needed a per-repo substitution would require template-matching (the canonical as a pattern, the copy as an instance), not this content hash. None does today. diff --git a/spec/third-party-tools.json b/spec/third-party-tools.json index f3bcc3a1..4eb5d42b 100644 --- a/spec/third-party-tools.json +++ b/spec/third-party-tools.json @@ -32,7 +32,7 @@ { "name": "NEbml", "link": "https://github.com/OlegZee/NEbml", "description": "EBML reader and writer for .NET." }, { "name": "Nerdbank.GitVersioning", "link": "https://github.com/dotnet/Nerdbank.GitVersioning", "description": "Version computation from git height." }, { "name": "PaperMod", "link": "https://github.com/adityatelange/hugo-PaperMod", "description": "Hugo theme." }, - { "name": "pre-commit", "link": "https://pre-commit.com/", "description": "Git hook manager for Python." }, + { "name": "pre-commit", "link": "https://pre-commit.com/", "description": "Git hook manager." }, { "name": "Proxmox VE", "link": "https://www.proxmox.com/en/proxmox-virtual-environment/overview", "description": "Virtualization platform." }, { "name": "regex101.com", "link": "https://regex101.com/", "description": "Regular expression tester." }, { "name": "RFC 5646 language tags", "link": "https://www.rfc-editor.org/rfc/rfc5646.html", "description": "Language tag standard." }, From 38600e99d030c4f14e0f6bc70d66540c58edf205 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 2 Sep 2026 06:46:17 -0700 Subject: [PATCH 2/3] Send a Repo Preferring pre-commit to the Right Hook Shape (#1189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers four findings raised on the #1187 promotion review, which need a feature cycle before the promotion can go green. Two title findings on that PR were fixed by retitling it and needed no code. **Runner preference was reversed.** The .NET skill's Tooling bullet said a repo `preferring that runner` takes the pre-commit shape, where the only runner that sentence names is Husky.Net. It read as routing a Husky.Net-preferring repo away from Husky.Net. It now names the `pre-commit` framework it means. **The deploy stub's comment grew to four lines.** Saying the required crossing secret already implies the task declares others, so the line spelling out the optional `SITE_AUTH_TOKEN_ID`/`SITE_AUTH_TOKEN` pair comes back out. Three lines, each one sentence. Two more surfaced while verifying those, in the same bullet: **The doc gates read as a Husky.Net property.** `covering the shared doc gates` hung on the Husky.Net branch alone with the pre-commit branch given as a bare alternative, so a repo taking that shape could wire only its language checks, which is a `parity.hooks` finding. `catalog/snippets/pre-commit/.pre-commit-config.yaml` does carry `prose-gate` and `eol-gate`, and GOVERNANCE.md says both shapes owe them. **Following the bullet literally produced a failing hook.** It named neither the per-clone install steps nor the `hub-fetch-run.py` copy, and routed nowhere, so a repo wiring `catalog/snippets/husky/` got a hook whose first line sources a `.husky/_/husky.sh` nothing had generated. It now routes to the snippet READMEs and the Build requirements section above, both of which carry those steps. Gates: `spec/validate.py`, `prose_lint.py`, `repo_gate.py`, `build_dist.py --check`, `spec/audit.py --selftest`, 1046 tests. ## Summary by CodeRabbit - **Documentation** - Clarified setup guidance for repositories using Husky.Net or the pre-commit framework. - Documented shared documentation gates and language checks covered by both configurations. - Added clearer references to each setup snippet’s README, companion file, and cloning steps. - **Chores** - Refreshed documentation audit metadata to reflect the latest guidance. - Removed an outdated self-test comment related to optional deployment authentication mappings. --- .agents/skills/dotnet-codestyle/SKILL.md | 7 ++++--- .claude-plugin/fleet-skills/.source-digest | 2 +- .../fleet-skills/skills/dotnet-codestyle/SKILL.md | 7 ++++--- .github/skills/dotnet-codestyle/SKILL.md | 7 ++++--- reports/canonical-review.json | 6 +++--- spec/audit.py | 1 - 6 files changed, 16 insertions(+), 14 deletions(-) diff --git a/.agents/skills/dotnet-codestyle/SKILL.md b/.agents/skills/dotnet-codestyle/SKILL.md index db41583e..a6a32c03 100644 --- a/.agents/skills/dotnet-codestyle/SKILL.md +++ b/.agents/skills/dotnet-codestyle/SKILL.md @@ -105,9 +105,10 @@ updates, dependency upgrades, benchmarks) on top: version management. - CI is the authoritative lint backstop. A repo already keeping a .NET tool manifest declaring Husky.Net wires its local pre-commit hook from `catalog/snippets/husky/` in the hub, hub-local - and not carried into every fleet repo, covering the shared doc gates alongside the language - checks. A repo keeping none, or one preferring that runner, takes `catalog/snippets/pre-commit/` - instead. + and not carried into every fleet repo. A repo keeping none, or one preferring the `pre-commit` + framework, takes `catalog/snippets/pre-commit/` instead. Either shape covers the shared doc + gates alongside the language checks. Each snippet's own README names the per-clone steps and + the second file to copy alongside it. - **Required VS Code extensions**: CSharpier, markdownlint, CSpell. Use the workspace settings without overrides. diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index c2108a0c..4e1b6d57 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -c7efdbdc80111e8f +a7bfc794d16c14d0 diff --git a/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md b/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md index db41583e..a6a32c03 100644 --- a/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md @@ -105,9 +105,10 @@ updates, dependency upgrades, benchmarks) on top: version management. - CI is the authoritative lint backstop. A repo already keeping a .NET tool manifest declaring Husky.Net wires its local pre-commit hook from `catalog/snippets/husky/` in the hub, hub-local - and not carried into every fleet repo, covering the shared doc gates alongside the language - checks. A repo keeping none, or one preferring that runner, takes `catalog/snippets/pre-commit/` - instead. + and not carried into every fleet repo. A repo keeping none, or one preferring the `pre-commit` + framework, takes `catalog/snippets/pre-commit/` instead. Either shape covers the shared doc + gates alongside the language checks. Each snippet's own README names the per-clone steps and + the second file to copy alongside it. - **Required VS Code extensions**: CSharpier, markdownlint, CSpell. Use the workspace settings without overrides. diff --git a/.github/skills/dotnet-codestyle/SKILL.md b/.github/skills/dotnet-codestyle/SKILL.md index db41583e..a6a32c03 100644 --- a/.github/skills/dotnet-codestyle/SKILL.md +++ b/.github/skills/dotnet-codestyle/SKILL.md @@ -105,9 +105,10 @@ updates, dependency upgrades, benchmarks) on top: version management. - CI is the authoritative lint backstop. A repo already keeping a .NET tool manifest declaring Husky.Net wires its local pre-commit hook from `catalog/snippets/husky/` in the hub, hub-local - and not carried into every fleet repo, covering the shared doc gates alongside the language - checks. A repo keeping none, or one preferring that runner, takes `catalog/snippets/pre-commit/` - instead. + and not carried into every fleet repo. A repo keeping none, or one preferring the `pre-commit` + framework, takes `catalog/snippets/pre-commit/` instead. Either shape covers the shared doc + gates alongside the language checks. Each snippet's own README names the per-clone steps and + the second file to copy alongside it. - **Required VS Code extensions**: CSharpier, markdownlint, CSpell. Use the workspace settings without overrides. diff --git a/reports/canonical-review.json b/reports/canonical-review.json index 68d04040..204cabd2 100644 --- a/reports/canonical-review.json +++ b/reports/canonical-review.json @@ -43,11 +43,11 @@ }, { "unit": ".agents/skills/dotnet-codestyle/SKILL.md > Tooling and editor", - "digest": "sha256:bee41f58a11d3ee23789d5839a26798dba19c89d1e500ec468c918f55a41cf40", + "digest": "sha256:adaa0bb212c931d838d35dc9777b8ac5605ff46ccffc4448f227c78793085a57", "reviewer": "agent-skill", "findings": 1, - "hubCommit": "a2c9dab3bfde574d179b0765f9bb50f413ce7a80", - "stamp": "2026-09-02T03:33:05Z" + "hubCommit": "68be56e977269486eeeca14bdc4982a0adbdb6b3", + "stamp": "2026-09-02T13:41:40Z" }, { "unit": ".agents/skills/drive-pr/SKILL.md > Disposing of Every Finding", diff --git a/spec/audit.py b/spec/audit.py index 448bf748..e715f47b 100755 --- a/spec/audit.py +++ b/spec/audit.py @@ -2982,7 +2982,6 @@ def _selftest(): ), ] # The deploy-site.yml caller stub once deploy-site-task.yml is hub-hosted: no secrets: inherit, the required crossing secret named instead. - # The task also declares an optional SITE_AUTH_TOKEN_ID/SITE_AUTH_TOKEN pair, which a caller maps only where its live check needs one. # inherit is documented for same-org or enterprise callers, so the fleet does not use it across repositories. # No job-level environment: on the caller, unsupported on a job with uses: (ptr727/ProjectTemplate#942). deploy_stub = ( From c4329e6de56ea42098a5a3f919724e331a86f73d Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Wed, 2 Sep 2026 07:02:23 -0700 Subject: [PATCH 3/3] Separate the Verbatim-Tree Comparison From the Content Hash (#1191) Answers three findings from the #1187 promotion review that need a feature cycle before the promotion can go green. **`verbatim-tree` uses blob identity, not the content hash.** `spec/fidelity-model.md` folded both byte-exact tiers into one bullet describing a content-hash comparison. Verified in `spec/audit.py`: tree entries compare through `canonical_blob_sha()` at 2524 and `git_blob_in_file_history()` at 2546, where `classify_verbatim()` at 1756 uses `content_hash()`. They are now separate bullets, each naming what it compares, and the tree bullet keeps its structural failure on a missing or extra path. **Adopter detail left the hub contract.** The Secrets and Permissions section named two adopters' call sites along with `CODECOV_TOKEN` and the Docker Hub pair. The shapes are the contract; those secret names are the adopting repositories' own detail. The section now states both shapes and that both run in the fleet, without the per-repo mappings. **Two before-and-after phrasings become present tense**, per the doc-style contract. Gates: `spec/validate.py`, `prose_lint.py`, `repo_gate.py`, `build_dist.py --check`, `spec/audit.py --selftest`, 1046 tests. No carried canonical unit changed. ## Summary by CodeRabbit * **Documentation** * Clarified that both catalog snippets include the shared quality gates. * Documented secret-audit behavior for reusable workflows, including inherited secrets and cross-repository handling. * Clarified deploy-site behavior for environment-scoped secrets, deploy hooks, environment variables, and optional authentication tokens. * Distinguished `verbatim` and `verbatim-tree` fidelity detection, including how paths and Git history are evaluated. --- docs/pre-commit-hooks-rollout.md | 2 +- docs/reusable-workflows.md | 4 ++-- spec/fidelity-model.md | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/pre-commit-hooks-rollout.md b/docs/pre-commit-hooks-rollout.md index eb1e4e3f..e5675f25 100644 --- a/docs/pre-commit-hooks-rollout.md +++ b/docs/pre-commit-hooks-rollout.md @@ -27,7 +27,7 @@ clean, stays operational, per the existing carve-out in GOVERNANCE.md. Two catal the canonical shape: Husky.Net (`catalog/snippets/husky/`, for a repo already keeping a .NET tool manifest declaring it) and the `pre-commit` framework (`catalog/snippets/pre-commit/`, for a repo without one and for any repo -that prefers it). Both now carry the +that prefers it). Both carry the same shared doc gates, the diff-scoped prose/comment-style gate and the whole-tree line-ending check, fetched fresh from the hub's `main` branch at run time via `hub-fetch-run.py` rather than vendored or pinned. diff --git a/docs/reusable-workflows.md b/docs/reusable-workflows.md index 9f423dd1..70f97b20 100644 --- a/docs/reusable-workflows.md +++ b/docs/reusable-workflows.md @@ -68,7 +68,7 @@ The sequencing consequence is that a hub task lands on `develop`, promotes to `m ### Secrets and Permissions -Every hub task declares the secrets it needs by name under `on.workflow_call.secrets`, and a caller maps each one explicitly. Most are `required: true`. A mechanism's secret is `required: false` where the task treats it as one of several opt-in targets, such as `DOCKER_HUB_USERNAME`/`DOCKER_HUB_ACCESS_TOKEN` in `build-release-task.yml`. A package-registry credential is not among them, since `NUGET_USERNAME` and the PyPI OIDC exchange are read by the caller stub's own `publish-nuget` / `publish-pypi` job rather than passed into the task, per [Adopting the Release Chain][adopting-the-release-chain]. The same names are `required: true` in a task built around that one mechanism instead, such as `DOCKER_HUB_USERNAME`/`DOCKER_HUB_ACCESS_TOKEN` in `build-docker-task.yml`. Whether `secrets: inherit` is used is decided by the call's own boundary, not by the fleet's preference. [GitHub documents the keyword][gh-reusing-workflows] for a caller in the same organization or enterprise as the called workflow, and the fleet is a personal account. So a cross-repository call to a hub task names each secret it passes, and `inherit` is never used on one. A call whose job needs none passes no `secrets:` key, which is what the [Adopting the Gates][adopting-the-gates] `validate` stub does. A call by local path stays inside one repository. There the caller's own secret store is the one the called workflow reads, so `inherit` is available. Availability is not a reason to use it, and this repository's own local-path calls name their secrets or pass none. Both shapes sit side by side in PlexCleaner's `test-pull-request.yml`. Its `validate` job calls `./.github/workflows/validate-task.yml` with `secrets: inherit`, to thread `CODECOV_TOKEN` through. Its `smoke-build` job names `DOCKER_HUB_USERNAME` and `DOCKER_HUB_ACCESS_TOKEN` explicitly for the hub's `build-release-task.yml`. The [Adopting the Gates][adopting-the-gates] smoke-build stub carries the local-path shape for the same reason. NxWitness proves an inherited value actually arrives. Its `publish-release.yml` calls its own `build-docker-task.yml` with `secrets: inherit` and `push: true`. That task authenticates to Docker Hub from the two inherited names. The declared names are the ones [`spec/secrets.json`][secrets] already declares for the mechanism the task implements, so the secret audit and the workflow agree by construction. An environment-scoped secret is the exception. `DEPLOY_SSH_PRIVATE_KEY` and the `SITE_AUTH_TOKEN_ID`/`SITE_AUTH_TOKEN` pair beside it cross a GitHub Environment boundary `spec/secrets.json` has no vocabulary for, per its `deploy-ssh` mechanism note. +Every hub task declares the secrets it needs by name under `on.workflow_call.secrets`, and a caller maps each one explicitly. Most are `required: true`. A mechanism's secret is `required: false` where the task treats it as one of several opt-in targets, such as `DOCKER_HUB_USERNAME`/`DOCKER_HUB_ACCESS_TOKEN` in `build-release-task.yml`. A package-registry credential is not among them, since `NUGET_USERNAME` and the PyPI OIDC exchange are read by the caller stub's own `publish-nuget` / `publish-pypi` job rather than passed into the task, per [Adopting the Release Chain][adopting-the-release-chain]. The same names are `required: true` in a task built around that one mechanism instead, such as `DOCKER_HUB_USERNAME`/`DOCKER_HUB_ACCESS_TOKEN` in `build-docker-task.yml`. Whether `secrets: inherit` is used is decided by the call's own boundary, not by the fleet's preference. [GitHub documents the keyword][gh-reusing-workflows] for a caller in the same organization or enterprise as the called workflow, and the fleet is a personal account. So a cross-repository call to a hub task names each secret it passes, and `inherit` is never used on one. A call whose job needs none passes no `secrets:` key, which is what the [Adopting the Gates][adopting-the-gates] `validate` stub does. A call by local path stays inside one repository. There the caller's own secret store is the one the called workflow reads, so `inherit` is available. Availability is not a reason to use it, and this repository's own local-path calls name their secrets or pass none. The [Adopting the Gates][adopting-the-gates] smoke-build stub carries the local-path shape for the same reason. Both shapes run in the fleet today, one repo carrying a local-path `inherit` call beside a cross-repository call that names its secrets, and another proving an inherited value reaches a publishing task that authenticates from it. The declared names are the ones [`spec/secrets.json`][secrets] already declares for the mechanism the task implements, so the secret audit and the workflow agree by construction. An environment-scoped secret is the exception. `DEPLOY_SSH_PRIVATE_KEY` and the `SITE_AUTH_TOKEN_ID`/`SITE_AUTH_TOKEN` pair beside it cross a GitHub Environment boundary `spec/secrets.json` has no vocabulary for, per its `deploy-ssh` mechanism note. A hub task declares no job-level `permissions:` where every write goes through the App token, and the caller sets `permissions: {}`. A called workflow can only keep or reduce the caller's grant. A callee job naming a scope the caller did not grant fails at startup even when its `if:` is false. Declaring nothing in the callee is therefore the shape that cannot fail against any caller, and it gives `GITHUB_TOKEN` no scope. A task whose job genuinely writes with `GITHUB_TOKEN`, such as a release upload, declares that scope in the callee job and documents it in the stub's comment so the caller grants it. @@ -611,7 +611,7 @@ The caller grants `contents: read` explicitly, since the task's own jobs declare ESPHome-NonRoot carries two trackers today. `check-upstream-version.yml` adopts the stub above as-is. `check-upstream-dependency.yml`, whose bump waits for a human because its head deliberately does not match a merge-bot rule, adopts a second instance of the same stub with `with: { branches: '["develop"]', bump-branch-prefix: upstream-dependency, auto-merge: false }` and a `resolve-upstream` hook shaped around its apt-package snapshot, setting `versions` to `{"docker_base_packages": ""}` rather than a name-to-version map. The generic title and body this produces read less specifically than today's bespoke "packages added/removed" wording, which is the cost of folding a bespoke tracker into the shared task. -**Deploy-site.** A site repo keeps `deploy-site.yml` as a per-repo caller (it has no manifest-wide catalog snippet either, since its `uses:` now names the hub, and it still carries the dispatch, the ref gate, and the shared validation call), but its `deploy` job reaches the hub-hosted `deploy-site-task.yml`. A job calling a reusable workflow cannot itself carry an `environment:` key ([GitHub's supported-keywords list][gh-reusing-workflows] omits it, and GitHub rejects the combination outright), so the caller's `deploy` job carries none. `DEPLOY_SSH_PRIVATE_KEY` still crosses correctly: the task's own `deploy` job binds `environment: ${{ inputs.environment }}` on itself, and per GitHub's own cross-repository behavior (the `github` context, and an OIDC token's `sub` claim, always attribute the environment to the *caller*), that resolves against the caller's own GitHub Environment store, not the hub's. The caller's `secrets: DEPLOY_SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}` line is still required (the task declares this a required `workflow_call.secrets` input, and GitHub rejects a call omitting a required one), but what value the task's own job actually sees for it is governed by the task's own `environment:` binding, not by anything the caller's own (environment-less) job context could resolve. Confirmed both against GitHub's documented reusable-workflow secrets mechanics and with a live cross-repository run against Blog's own `staging` environment ([issue #942][issue-942]). `secrets: inherit` was never the alternative here regardless. It is not used on a cross-repository call at all, per [Secrets and Permissions][secrets-and-permissions], and it would not carry an environment-scoped secret across one either. A required `deploy` hook, `.github/actions/deploy/action.yml`, is invoked three times (`build`, `prune`, `verify`) so the site keeps its own generator, precompression, and URL contract while the upload-then-flip sequence stays hub-owned. The hook declares all four inputs the three invocations use between them, `mode`, `bundle-path`, `release-id`, and `environment`, since a composite action rejects an invocation that supplies an input it does not declare, even one a different mode leaves unset. Each invocation also passes the GitHub Environment variables that mode needs (`SITE_BASE_URL` to `build` and `verify`, `DEPLOY_SSH_USER` and `DEPLOY_SSH_HOST` to `prune`) as plain `env:` vars, since a composite action's own steps are not guaranteed to read the caller's `vars` context directly. `verify` additionally receives an optional `SITE_AUTH_TOKEN_ID`/`SITE_AUTH_TOKEN` secret pair the same way, forwarded whenever the caller maps it. A site whose live check sits behind its own token-gated auth is the reason it exists. +**Deploy-site.** A site repo keeps `deploy-site.yml` as a per-repo caller (it has no manifest-wide catalog snippet either, since its `uses:` names the hub, and it still carries the dispatch, the ref gate, and the shared validation call), but its `deploy` job reaches the hub-hosted `deploy-site-task.yml`. A job calling a reusable workflow cannot itself carry an `environment:` key ([GitHub's supported-keywords list][gh-reusing-workflows] omits it, and GitHub rejects the combination outright), so the caller's `deploy` job carries none. `DEPLOY_SSH_PRIVATE_KEY` still crosses correctly: the task's own `deploy` job binds `environment: ${{ inputs.environment }}` on itself, and per GitHub's own cross-repository behavior (the `github` context, and an OIDC token's `sub` claim, always attribute the environment to the *caller*), that resolves against the caller's own GitHub Environment store, not the hub's. The caller's `secrets: DEPLOY_SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}` line is still required (the task declares this a required `workflow_call.secrets` input, and GitHub rejects a call omitting a required one), but what value the task's own job actually sees for it is governed by the task's own `environment:` binding, not by anything the caller's own (environment-less) job context could resolve. Confirmed both against GitHub's documented reusable-workflow secrets mechanics and with a live cross-repository run against Blog's own `staging` environment ([issue #942][issue-942]). `secrets: inherit` was never the alternative here regardless. It is not used on a cross-repository call at all, per [Secrets and Permissions][secrets-and-permissions], and it would not carry an environment-scoped secret across one either. A required `deploy` hook, `.github/actions/deploy/action.yml`, is invoked three times (`build`, `prune`, `verify`) so the site keeps its own generator, precompression, and URL contract while the upload-then-flip sequence stays hub-owned. The hook declares all four inputs the three invocations use between them, `mode`, `bundle-path`, `release-id`, and `environment`, since a composite action rejects an invocation that supplies an input it does not declare, even one a different mode leaves unset. Each invocation also passes the GitHub Environment variables that mode needs (`SITE_BASE_URL` to `build` and `verify`, `DEPLOY_SSH_USER` and `DEPLOY_SSH_HOST` to `prune`) as plain `env:` vars, since a composite action's own steps are not guaranteed to read the caller's `vars` context directly. `verify` additionally receives an optional `SITE_AUTH_TOKEN_ID`/`SITE_AUTH_TOKEN` secret pair the same way, forwarded whenever the caller maps it. A site whose live check sits behind its own token-gated auth is the reason it exists. Blog is the reference adoption, and its real inventory is two scripts, not three: `deploy/make-release.sh` assembles, hard-links, stamps, and installs a release into whatever root it is pointed at, and `checks/check-live-urls.sh` verifies one against a running server. There is no `deploy/prune-releases.sh`. `build` mode wraps `make-release.sh` pointed at the hub-passed `bundle-path` rather than a live root, alongside whatever generator setup the hook itself needs, Hugo and brotli in Blog's case, that `make-release.sh` assumes are already on `PATH`. The script's own tail, a swap of a local `current` symlink to the release it just wrote and a check that the swap hard-linked something against whatever `current` pointed at before, runs entirely against that ephemeral `bundle-path`, so it is local bookkeeping rather than a second real deploy. It is also what leaves `bundle-path/current` in place for the hub task's own build-mode assertion to find. Because `bundle-path` is empty at the start of every run, that local `current` never resolves to anything and the hard-link check never has a previous release to compare against, so it is inert in CI. The `build` hook's own `current` is never the live one either way: only the hub-owned Upload release and Flip current steps that follow touch the real `//` root, so the boundary the upload-then-flip sequence draws is between `bundle-path` and the environment, not a seam inside `make-release.sh` itself. diff --git a/spec/fidelity-model.md b/spec/fidelity-model.md index 7407c733..eb91f1fc 100644 --- a/spec/fidelity-model.md +++ b/spec/fidelity-model.md @@ -38,7 +38,8 @@ A downstream repo editing carried content is a different question from a copy tr The unit decides, never the file. `GOVERNANCE.md` is `intent` as a file while 18 of its 20 declared sections are `verbatim`. An edit inside one of those is caught, and an edit to the prose around them is not. -- **verbatim and verbatim-tree** - detected. The content hash is compared against the canonical and every past revision, and "Stale Versus Modified" below reads the result. An edit matching no revision reports as modified fixed content, and one restoring an earlier revision reports as stale, since the hash cannot say how the bytes got there. A `verbatim-tree` declaration also fails structurally, on a missing included path or an extra one under a pruned root, before any hash is read. +- **verbatim** - detected. The content hash is compared against the canonical and every past revision, and "Stale Versus Modified" below reads the result. An edit matching no revision reports as modified fixed content, and one restoring an earlier revision reports as stale, since the hash cannot say how the bytes got there. +- **verbatim-tree** - detected, by git blob identity rather than by that content hash. A tree entry is compared against the canonical blob and, on a mismatch, against that path's blob history, which gives the same stale-or-modified split. It also fails structurally, on a missing included path or an extra one under a pruned root. - **interface** - detected only where the edit moves the contract. A changed job key, check name, or hub task token is a finding. An edit to the owned body is the override this tier exists for. - **intent** - not detected. Content is never judged, and the staleness advisory never identifies a local edit. It compares last-change dates, so the edit first suppresses the advisory by making the copy newer. After the hub's next substantive change to that file, meaning one the line-ending, action-pin, and job-needs normalization does not explain away, the advisory fires and reports the copy as trailing, which routes to a re-vendor that overwrites the edit rather than to the edit itself. - **presence** - a content edit is not detected. The unit is asserted to exist and is never read, so deleting it is still a finding and editing it is not.