diff --git a/.agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md b/.agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md index a95b7349..8bfd49a6 100644 --- a/.agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md +++ b/.agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md @@ -34,8 +34,9 @@ standardization baseline: `publish-release.yml` (single-branch publish plan), th task plus `github-release` job inside `build-release-task.yml`, `get-version-task.yml`, and the aggregator shape of `test-pull-request.yml`. Within `test-pull-request.yml`, only the `changes -> smoke-build -> check-workflow-status` aggregator -wiring and the ruleset-bound job name are verbatim orchestration, while the `unit-test` job and -the `dorny/paths-filter` entries are owned/per-target. The **build** layer is a hook: a composite +wiring and the ruleset-bound job name are verbatim orchestration, while the `dorny/paths-filter` +entries are owned/per-target. The validation job is a call to the reusable validator, whose own +jobs a caller cannot address. The **build** layer is a hook: a composite action at `.github/actions/build-` the hub-hosted `build-release-task.yml` reaches. The hub defaults require explicit project paths. A project needing more than a path override carries its own hook. @@ -53,7 +54,7 @@ looks tidier for 1:1 but forks the `github-release` download and breaks its verb **What a repo still curates** (by design, not a leak): which `enable_` inputs its caller stub sets, per the per-target subsetting rule above. `build-release-task.yml` is hub-hosted -(`docs/reusable-workflows.md` "Stage 4: The Release Chain and the Docker Core"), so its job graph +(the hub's `docs/reusable-workflows.md` "Stage 4: The Release Chain and the Docker Core"), so its job graph and its `github-release` job are the hub's, not a per-repo file a caller edits. A repo adopting the release chain carries only the caller stub in its own `publish-release.yml` and `test-pull-request.yml`, naming the hub task by pin and setting the `enable_*`, `docker_image`, @@ -116,9 +117,9 @@ version/tag, which is heavyweight but expected even for a non-.NET repo, and acc ## No-op republish guarantee -A weekly/dispatch publish where NBGV `SemVer2` is **unchanged** (no new commit since the last +A scheduled or push publish where NBGV `SemVer2` is **unchanged** (no new commit since the last publish) re-pushes **nothing** to GitHub Releases (the `github-release` job's `release-exists` -check skips the create step), NuGet (`dotnet nuget push --skip-duplicate`), or PyPI +check skips the create step, and a dispatch refreshes the release instead of skipping), NuGet (`dotnet nuget push --skip-duplicate`), or PyPI (`gh-action-pypi-publish` `skip-existing: true`), since all three key on the version string. **Docker always re-pushes** by design: it picks up upstream base-image refreshes (e.g. `ubuntu:rolling`) that aren't visible in the repo. Boundary: `version.json` has **no diff --git a/.agents/skills/workflow-ci-contract/SKILL.md b/.agents/skills/workflow-ci-contract/SKILL.md index 9eaf908f..9cf6432d 100644 --- a/.agents/skills/workflow-ci-contract/SKILL.md +++ b/.agents/skills/workflow-ci-contract/SKILL.md @@ -1,7 +1,7 @@ --- name: workflow-ci-contract description: >- - Governs the WORKFLOW.md CI/CD behavioral contract for every ptr727/ProjectTemplate fleet repo: the D1-D9 guarantees stated as the failure mode each prevents, the seam contract for release assets, the artifact lifecycle, NBGV versioning and classification, validate-at-entry, and the 5A/5B/5C test methodology with its per-type walkthroughs. Use this whenever writing or editing anything under .github/workflows/, adding or dropping a release target, auditing a repo's workflows, or reasoning about why a publish did or did not fire. This is the YAML half of the pipeline, and the operational-vs-release-workflow skill keeps the git half (branching, promotion, publish policy), so branch choice questions go there. Triggers even when the edit looks mechanical, such as bumping an action, renaming a job, or adding one upload step, because SHA pinning, the ruleset-bound aggregator name, smoke gating on uploads, and retention-days are each easy to break in a one-line diff that no smoke build exercises, since workflow-only changes are deliberately not smoke-built. WORKFLOW.md keeps authority, and GOVERNANCE.md wins where the two overlap. + Governs the WORKFLOW.md CI/CD behavioral contract for every ptr727/ProjectTemplate fleet repo: the D1-D9 guarantees, the seam contract for release assets, the artifact lifecycle, NBGV versioning and classification, validate-at-entry, and the 5A/5B/5C test methodology with its per-type walkthroughs. Use this whenever writing or editing anything under .github/workflows/, adding or dropping a release target, auditing a repo's workflows, or reasoning about why a publish did or did not fire. This is the YAML half of the pipeline, and the operational-vs-release-workflow skill keeps the git half (branching, promotion, publish policy), so branch choice questions go there. Triggers even when the edit looks mechanical, such as bumping an action, renaming a job, or adding one upload step, because SHA pinning, the ruleset-bound aggregator name, smoke gating on uploads, and retention-days are each easy to break in a one-line diff that no smoke build exercises, since workflow-only changes are deliberately not smoke-built. WORKFLOW.md keeps authority, and GOVERNANCE.md wins where the two overlap. --- # Workflow CI Contract @@ -32,15 +32,15 @@ description: >- ## The Core Behavioral Spine -- **PRs validate fast and never publish**: a paths-filter smoke-builds only changed targets, a type-appropriate validation job always runs, and one required aggregator gates the merge, treating skipped smoke as pass and blocking on failure or cancelled. Smoke does a full compile/lint/test but pushes nothing and uploads nothing, every `upload-artifact` gated `!smoke`. +- **PRs validate fast and never publish**: a paths-filter smoke-builds only changed targets, the caller's own job reaching the reusable validator, or the replacement it points its aggregator at, always runs, and one required aggregator gates the merge, treating skipped smoke as pass and blocking on failure or cancelled. Smoke does a full compile/lint/test but pushes nothing and uploads nothing, every `upload-artifact` gated on smoke being false, which is `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings. - **A human merge never auto-publishes**: a `plan` job decides once and every job gates on it. Publishes come from a code-affecting bot push to `main`, a manual dispatch of `main` or `develop`, or the main-only weekly Docker schedule. Each run builds the one trigger branch, `main` a clean `X.Y.Z`, anything else a prerelease `X.Y.Z-g`, with NBGV owning the patch from git height. The release tags the built commit's SHA (`GitCommitId`), never a branch name. - **Validate at entry**: cross-input and input-versus-derived-state invariants are asserted once in a dedicated entry job the downstream jobs `needs:`, failing fast with `::error::` before expensive work. The release gate checks branch-versus-prerelease in both directions, strips `+buildmetadata`, and on smoke skips the check while the job still succeeds. - **The seam contract**: a target contributes a release file by uploading `release-asset--`, and the release job collects by `pattern:` plus `merge-multiple:`, never `artifact-ids:`, canonical even for a single target. A repo with no file target passes `expect_release_assets: false` at the caller. -- **Artifacts are an intra-run handoff**: consume-then-delete at the point of consumption, gated to the consumer's condition, best-effort, `retention-days: 1` on every upload as the backstop, and never a blanket delete of the run's artifact set, which destroys the diagnostics you need when the run fails. -- **No-op republish**: an unchanged version re-pushes nothing, the release-create step skips when the tag exists, registries dedupe server-side (`--skip-duplicate`, `skip-existing: true`), and Docker alone always re-pushes by design. -- **A build failure blocks every publish target**: `github-release` needs every build, the terminal registry pusher guards with `!failure() && !cancelled()`, and a package target's separate `publish-` job `needs:` the release-task call, so nothing partial ships. +- **Artifacts are an intra-run handoff**: consume-then-delete at the point of consumption, gated to the half of the consumption whose failure would leave the artifact not yet redundant, best-effort, `retention-days: 1` on every upload as the backstop, and never a blanket delete of the run's artifact set, which destroys the diagnostics you need when the run fails. +- **No-op republish**: an unchanged version re-pushes nothing, the release-create step skips when the tag exists and is refreshed only on a dispatch, registries dedupe server-side (`--skip-duplicate`, `skip-existing: true`), and Docker alone always re-pushes by design. +- **A build failure blocks every publish target**: `github-release` needs every build and guards with `!failure() && !cancelled()` as the terminal registry pusher (Docker) does, and a package target's separate `publish-` job `needs:` the release-task call, so no build failure ships anything partial. A failed package push is outside that, since it runs after the release is cut. -The full catalog, each guarantee with the failure mode it prevents, is in `references/d-guarantees.md`. Auditing, tracing, and probing a repo's workflows is `references/test-methodology.md`. +A condensed catalog of `WORKFLOW.md` section 4 is in `references/d-guarantees.md`. A condensed guide to `WORKFLOW.md` section 5's audit, trace, and probe procedure is in `references/test-methodology.md`. ## After Any Workflow Edit diff --git a/.agents/skills/workflow-ci-contract/references/d-guarantees.md b/.agents/skills/workflow-ci-contract/references/d-guarantees.md index b32348b9..a49f42ce 100644 --- a/.agents/skills/workflow-ci-contract/references/d-guarantees.md +++ b/.agents/skills/workflow-ci-contract/references/d-guarantees.md @@ -1,15 +1,15 @@ # The D-Guarantees, Condensed -Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as input to output plus the failure mode it prevents. This is the condensed catalog for working from, and `WORKFLOW.md` keeps authority, so read the section there when a guarantee's exact wording decides a verdict. +Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as the output a conforming pipeline produces. In that section an item names an input only where the guarantee applies to a particular trigger or state, and names the failure it prevents only where the output does not already show it. An item naming neither still binds every repo whose shape its domain covers, and a workflow violating any applicable guarantee is not operational. This is the condensed catalog for working from, and `WORKFLOW.md` keeps authority: read the section there when a guarantee's exact wording decides a verdict, since a condensed item can be shorter than the one it condenses. ## D1: PR Fast-Feedback (Smoke) -- **D1.1** Only changed targets build: each target has a paths-filter entry, unchanged targets skip. Prevents a changed target slipping through unbuilt. -- **D1.2** A validation job always runs on any PR, and a non-.NET repo replaces it (never deletes it), re-pointing every `needs:` on it, the aggregator and `smoke-build` both. Prevents a PR merging with no validation, or a dangling `needs:` failing the workflow to load. -- **D1.3** Smoke never publishes and never uploads: full compile/lint/test, no pushes, every `upload-artifact` gated `!smoke`. Prevents a PR publishing and orphaned artifacts. -- **D1.4** Workflow-file changes are not smoke-built (the filter excludes `.github/workflows/**`), actionlint still validates them. -- **D1.5** One required aggregator gates merge: `needs:` the changes and validation jobs, passes on skipped smoke, blocks on failure or cancelled, and its name is ruleset-bound (job `name:` equals ruleset `context:`, renamed together). -- **D1.6** Coverage reports to Codecov for C# and Python repos with tests, best-effort so an outage never reds the gate, with a `codecov.yml` setting statuses informational and `.gitignore` excluding coverage output. +- **D1.1** Only changed targets build: each target has a paths-filter entry naming the paths it is built from, unchanged targets skip, and a change touching no target's paths marks nothing. A filter written as a negation of what must not build marks a docs-only change as a target change and fails this item. Prevents a changed target slipping through unbuilt. +- **D1.2** A validation job always runs on any PR: the caller's own job reaching the reusable validator, named `validate` in every shipped stub, which is the name the aggregator `needs:`. The validator's internal jobs are not addressable from a caller, and one of the hub's is itself called `validate`, so the matching name in a `needs:` list is always the caller's own job. It detects the tree rather than the language, so a non-.NET repo calls the same validator. A repo whose validation it cannot express replaces the call (never deletes it) and re-points the aggregator's `needs:`. `smoke-build` `needs:` the `changes` job, not the validation job. Prevents a PR merging with no validation, or a dangling `needs:` that stops the whole workflow from loading. +- **D1.3** Smoke never publishes and never uploads: full compile/lint/test, no pushes, every `upload-artifact` gated on smoke being false, `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings. Prevents a PR publishing and orphaned artifacts. +- **D1.4** A PR changing only `.github/workflows/**` is not smoke-built, since an inclusion list satisfying D1.1 matches no workflow path, and actionlint still validates them. +- **D1.5** One required aggregator gates merge: `if: always()`, `needs:` the validation job plus the `changes` and `smoke-build` jobs wherever the repo has a smoke build, passes on skipped smoke, blocks on failure or cancelled, and its name is ruleset-bound (job `name:` equals ruleset `context:`, renamed together). +- **D1.6** Coverage reports to Codecov for C# and Python repos with tests, a lint-only profile for that type excepted, best-effort so an outage never reds the gate, with a `codecov.yml` setting statuses informational and `.gitignore` excluding coverage output. ## D2: Validation at Entry @@ -29,16 +29,16 @@ Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as input to output ## D4: Release and Publish - **D4.1** Gated single-branch publish: a human merge never auto-publishes, the `plan` job decides once, publishes come from a code-affecting bot push to `main`, a dispatch of `main`/`develop`, or the main-only weekly Docker schedule. -- **D4.2** `target_commitish` is the built commit's SHA (NBGV `GitCommitId`), never a branch name and never `github.sha`. -- **D4.3** Every release is a tag plus source zip, README, and LICENSE, file targets attach `release-asset-*`, and a no-file-target caller passes `expect_release_assets: false` or the release-create step fails on unmatched files. -- **D4.4** No-op republish: an unchanged version re-pushes nothing, the release-create skips when the tag exists (refreshed only on `workflow_dispatch`), registries dedupe server-side, and Docker always re-pushes by design. -- **D4.5** A failed build blocks every publish target: `github-release` needs every build, the terminal registry pusher guards `!failure() && !cancelled()`, and a package target's separate `publish-` job `needs:` the release-task call, so nothing partial ships. +- **D4.2** `target_commitish` is the built commit's SHA (NBGV `GitCommitId`), never a branch name and never a separately re-resolved ref. +- **D4.3** Every release is a tag plus source zip, README, and LICENSE, `prerelease` equals `branch != default`, file targets attach `release-asset-*`, and a no-file-target caller (Docker-only, PyPI-only, source-only) passes `expect_release_assets: false` or the release-create step fails on unmatched files, a source-only one setting every `enable_*` input false with it. A NuGet caller is not one of those, since its leaf uploads a `release-asset-*` carrying the package. +- **D4.4** No-op republish on a schedule or push trigger: an unchanged version re-pushes nothing and the release-create skips when the tag exists, while a dispatch re-run refreshes it and runs the paired asset delete with it, registries dedupe server-side under `dotnet nuget push --skip-duplicate` and PyPI's `skip-existing: true`, and Docker always re-pushes by design. +- **D4.5** A failed build blocks every publish target: `github-release` needs every build and the terminal registry pusher (Docker) needs every other build, both guarding `!failure() && !cancelled()` so a disabled or unchanged target, skipped rather than failed, still lets the release be cut and the image pushed, and a package target's separate `publish-` job `needs:` the release-task call, so no build failure ships anything partial. A failed **package** push is outside that: the `publish-` job runs after the whole release task and so after `github-release`, and can leave a release and tag for a version the registry never received. The recovery is a re-dispatch rather than a cleanup, and it repairs that version only while the branch tip has not moved, since NBGV derives the version from git height, so a re-dispatch after the tip has moved publishes a further version and leaves the first release without its package. - **D4.6** A deploy check asserts which release and which environment answer, waiting for convergence to a bounded timeout, with an unreachable host reported distinctly from an HTTP status. ## D5: Resource Cleanup -- **D5.1** A cross-job transfer artifact is deleted at its point of consumption. An in-run intermediate may rely on the retention backstop. -- **D5.2** The delete runs under the same condition as its consumer, so a no-op re-run skips the release-asset delete while the `nuget-build-*` and `pypi-build-*` deletes still run. +- **D5.1** A cross-job transfer artifact is deleted by exact name or pattern at its point of consumption. An in-run intermediate may rely on the retention backstop. +- **D5.2** The delete runs exactly when the consumption happened: the same condition as a conditional consumer (the release create), and `if: ${{ !cancelled() && steps..outcome == 'success' }}` where the consumer is a push that always attempts, since a delete with no status-check function in its `if:` inherits `success()` and would skip on the failed push. So a no-op re-run that is not a dispatch skips the release-asset delete while the `nuget-build-*` and `pypi-build-*` deletes still run, and a dispatch re-run refreshes the release and runs the asset delete with it. - **D5.3** Cleanup is best-effort (`continue-on-error`, tolerate a failed listing, delete all matching ids). - **D5.4** Every `upload-artifact` sets `retention-days: 1`. - **D5.5** Never blanket-delete the run's artifacts, which destroys diagnostics and auto-emitted build records. @@ -56,7 +56,7 @@ Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as input to output - **D7.1** The publisher serializes: global ref-independent concurrency group, `cancel-in-progress: false`. - **D7.2** A reusable job declares `permissions:` only where every caller grants that scope at startup (the block is validated before `if:`), and otherwise declares none and runs under the calling job's grant, a callee's extra scope granted by the caller at the one entry point needing it. - **D7.3** Boolean inputs are declared in both trigger blocks and compared against both forms. -- **D7.4** Optional-dependency chaining allowlists `success`/`skipped` explicitly. +- **D7.4** Optional-dependency chaining allowlists `success`/`skipped` explicitly, beside a status-check function, since the implicit `success()` is false the moment any `needs:` job skipped. ## D8: Bots and Automation diff --git a/.agents/skills/workflow-ci-contract/references/test-methodology.md b/.agents/skills/workflow-ci-contract/references/test-methodology.md index ecd09487..dfda1191 100644 --- a/.agents/skills/workflow-ci-contract/references/test-methodology.md +++ b/.agents/skills/workflow-ci-contract/references/test-methodology.md @@ -4,7 +4,11 @@ The three escalating verification modes from `WORKFLOW.md` section 5, which keep ## 5A: Static Audit -Read the workflow files plus `version.json` and assert the structural fact behind each applicable D-guarantee, each pass, fail, or N/A with a `file:line` citation, asserting each input in the layer that declares it. The core sweep covers: the paths-filter's target coverage and `.github/workflows/**` exclusion, smoke gating on every upload, the aggregator's `needs:` and skip/fail handling, the entry validation jobs and the two-directional release gate, the single-branch NBGV classification and the three default-branch literals agreeing, `target_commitish` from `GitCommitId`, the consume-then-delete artifact lifecycle with `retention-days: 1` everywhere and no blanket delete, the `pattern:` handoff and `inputs.branch` config, the publisher's serialized concurrency, and the SHA pins. `WORKFLOW.md` 5A lists the per-type addenda (console runtime matrix, NuGet `--skip-duplicate`, the PyPI OIDC environment split, Docker `expect_release_assets` and cache shape, the static-site deploy gates), so apply only the ones the repo's types imply. +Read the workflow files plus `version.json` and assert the structural fact behind each applicable D-guarantee, each pass, fail, or N/A with a `file:line` citation, remembering the two layers and asserting each input in the file that declares it. `WORKFLOW.md` 5A carries the whole core list and the per-type addenda, and the sibling `d-guarantees.md` carries the guarantees each item answers to, so read this as an index into them rather than as the sweep itself. + +The core sweep reaches the paths-filter, naming each target's own build paths so a change touching none marks nothing. It reaches smoke gating on every upload. It reaches the aggregator's `needs:` and its skip and fail handling. It reaches the entry validation jobs and the two-directional release gate. It reaches the single-branch NBGV classification, with the gate's default-branch literal, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec` all naming the repo's actual default branch. It reaches `target_commitish` from `GitCommitId`. It reaches the consume-then-delete artifact lifecycle, with `retention-days: 1` everywhere and no blanket delete. It reaches the `pattern:` handoff and `inputs.branch` config. It reaches the publisher's serialized concurrency and the SHA pins. + +The per-type addenda cover .NET publish, NuGet, PyPI, Docker, and a static site deployed to a host, several assertions each. Apply only the ones the repo's types imply, and read them in `WORKFLOW.md` 5A rather than from this list. ## 5B: Trace Scenarios @@ -14,7 +18,7 @@ For each applicable scenario, evaluate every job's `if:`/`needs:` against the in - **S5/S6** a bot push to `main`: publishes only when code-affecting, and a human push never does. - **S7** a publish run builds the one trigger branch with the right classification and leaves no dangling artifacts. - **S8** a dispatch from a ref other than `main`/`develop` fails fast. -- **S9** a no-op re-run: release-create skipped, registries dedupe, PyPI build artifact still deleted, Docker still re-pushes. +- **S9** a no-op re-run on a schedule or push trigger: release-create skipped, registries dedupe, package build artifacts still deleted, Docker still re-pushes. A dispatch re-run refreshes the release instead. - **S10** branch and version classification disagree: the gate fails loud and everything downstream skips. - **S12/S13** a deploy dispatch: ref gate first, environment re-asserted, pointer flip separate, live check names the release, and a production deploy from a non-default ref fails before anything is written. diff --git a/.claude-plugin/fleet-skills/.source-digest b/.claude-plugin/fleet-skills/.source-digest index 4e1b6d57..01ad8951 100644 --- a/.claude-plugin/fleet-skills/.source-digest +++ b/.claude-plugin/fleet-skills/.source-digest @@ -1 +1 @@ -a7bfc794d16c14d0 +9cf4ad34f6781963 diff --git a/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/release-publish-mechanics.md b/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/release-publish-mechanics.md index a95b7349..8bfd49a6 100644 --- a/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/release-publish-mechanics.md +++ b/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/references/release-publish-mechanics.md @@ -34,8 +34,9 @@ standardization baseline: `publish-release.yml` (single-branch publish plan), th task plus `github-release` job inside `build-release-task.yml`, `get-version-task.yml`, and the aggregator shape of `test-pull-request.yml`. Within `test-pull-request.yml`, only the `changes -> smoke-build -> check-workflow-status` aggregator -wiring and the ruleset-bound job name are verbatim orchestration, while the `unit-test` job and -the `dorny/paths-filter` entries are owned/per-target. The **build** layer is a hook: a composite +wiring and the ruleset-bound job name are verbatim orchestration, while the `dorny/paths-filter` +entries are owned/per-target. The validation job is a call to the reusable validator, whose own +jobs a caller cannot address. The **build** layer is a hook: a composite action at `.github/actions/build-` the hub-hosted `build-release-task.yml` reaches. The hub defaults require explicit project paths. A project needing more than a path override carries its own hook. @@ -53,7 +54,7 @@ looks tidier for 1:1 but forks the `github-release` download and breaks its verb **What a repo still curates** (by design, not a leak): which `enable_` inputs its caller stub sets, per the per-target subsetting rule above. `build-release-task.yml` is hub-hosted -(`docs/reusable-workflows.md` "Stage 4: The Release Chain and the Docker Core"), so its job graph +(the hub's `docs/reusable-workflows.md` "Stage 4: The Release Chain and the Docker Core"), so its job graph and its `github-release` job are the hub's, not a per-repo file a caller edits. A repo adopting the release chain carries only the caller stub in its own `publish-release.yml` and `test-pull-request.yml`, naming the hub task by pin and setting the `enable_*`, `docker_image`, @@ -116,9 +117,9 @@ version/tag, which is heavyweight but expected even for a non-.NET repo, and acc ## No-op republish guarantee -A weekly/dispatch publish where NBGV `SemVer2` is **unchanged** (no new commit since the last +A scheduled or push publish where NBGV `SemVer2` is **unchanged** (no new commit since the last publish) re-pushes **nothing** to GitHub Releases (the `github-release` job's `release-exists` -check skips the create step), NuGet (`dotnet nuget push --skip-duplicate`), or PyPI +check skips the create step, and a dispatch refreshes the release instead of skipping), NuGet (`dotnet nuget push --skip-duplicate`), or PyPI (`gh-action-pypi-publish` `skip-existing: true`), since all three key on the version string. **Docker always re-pushes** by design: it picks up upstream base-image refreshes (e.g. `ubuntu:rolling`) that aren't visible in the repo. Boundary: `version.json` has **no diff --git a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md index 9eaf908f..9cf6432d 100644 --- a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md @@ -1,7 +1,7 @@ --- name: workflow-ci-contract description: >- - Governs the WORKFLOW.md CI/CD behavioral contract for every ptr727/ProjectTemplate fleet repo: the D1-D9 guarantees stated as the failure mode each prevents, the seam contract for release assets, the artifact lifecycle, NBGV versioning and classification, validate-at-entry, and the 5A/5B/5C test methodology with its per-type walkthroughs. Use this whenever writing or editing anything under .github/workflows/, adding or dropping a release target, auditing a repo's workflows, or reasoning about why a publish did or did not fire. This is the YAML half of the pipeline, and the operational-vs-release-workflow skill keeps the git half (branching, promotion, publish policy), so branch choice questions go there. Triggers even when the edit looks mechanical, such as bumping an action, renaming a job, or adding one upload step, because SHA pinning, the ruleset-bound aggregator name, smoke gating on uploads, and retention-days are each easy to break in a one-line diff that no smoke build exercises, since workflow-only changes are deliberately not smoke-built. WORKFLOW.md keeps authority, and GOVERNANCE.md wins where the two overlap. + Governs the WORKFLOW.md CI/CD behavioral contract for every ptr727/ProjectTemplate fleet repo: the D1-D9 guarantees, the seam contract for release assets, the artifact lifecycle, NBGV versioning and classification, validate-at-entry, and the 5A/5B/5C test methodology with its per-type walkthroughs. Use this whenever writing or editing anything under .github/workflows/, adding or dropping a release target, auditing a repo's workflows, or reasoning about why a publish did or did not fire. This is the YAML half of the pipeline, and the operational-vs-release-workflow skill keeps the git half (branching, promotion, publish policy), so branch choice questions go there. Triggers even when the edit looks mechanical, such as bumping an action, renaming a job, or adding one upload step, because SHA pinning, the ruleset-bound aggregator name, smoke gating on uploads, and retention-days are each easy to break in a one-line diff that no smoke build exercises, since workflow-only changes are deliberately not smoke-built. WORKFLOW.md keeps authority, and GOVERNANCE.md wins where the two overlap. --- # Workflow CI Contract @@ -32,15 +32,15 @@ description: >- ## The Core Behavioral Spine -- **PRs validate fast and never publish**: a paths-filter smoke-builds only changed targets, a type-appropriate validation job always runs, and one required aggregator gates the merge, treating skipped smoke as pass and blocking on failure or cancelled. Smoke does a full compile/lint/test but pushes nothing and uploads nothing, every `upload-artifact` gated `!smoke`. +- **PRs validate fast and never publish**: a paths-filter smoke-builds only changed targets, the caller's own job reaching the reusable validator, or the replacement it points its aggregator at, always runs, and one required aggregator gates the merge, treating skipped smoke as pass and blocking on failure or cancelled. Smoke does a full compile/lint/test but pushes nothing and uploads nothing, every `upload-artifact` gated on smoke being false, which is `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings. - **A human merge never auto-publishes**: a `plan` job decides once and every job gates on it. Publishes come from a code-affecting bot push to `main`, a manual dispatch of `main` or `develop`, or the main-only weekly Docker schedule. Each run builds the one trigger branch, `main` a clean `X.Y.Z`, anything else a prerelease `X.Y.Z-g`, with NBGV owning the patch from git height. The release tags the built commit's SHA (`GitCommitId`), never a branch name. - **Validate at entry**: cross-input and input-versus-derived-state invariants are asserted once in a dedicated entry job the downstream jobs `needs:`, failing fast with `::error::` before expensive work. The release gate checks branch-versus-prerelease in both directions, strips `+buildmetadata`, and on smoke skips the check while the job still succeeds. - **The seam contract**: a target contributes a release file by uploading `release-asset--`, and the release job collects by `pattern:` plus `merge-multiple:`, never `artifact-ids:`, canonical even for a single target. A repo with no file target passes `expect_release_assets: false` at the caller. -- **Artifacts are an intra-run handoff**: consume-then-delete at the point of consumption, gated to the consumer's condition, best-effort, `retention-days: 1` on every upload as the backstop, and never a blanket delete of the run's artifact set, which destroys the diagnostics you need when the run fails. -- **No-op republish**: an unchanged version re-pushes nothing, the release-create step skips when the tag exists, registries dedupe server-side (`--skip-duplicate`, `skip-existing: true`), and Docker alone always re-pushes by design. -- **A build failure blocks every publish target**: `github-release` needs every build, the terminal registry pusher guards with `!failure() && !cancelled()`, and a package target's separate `publish-` job `needs:` the release-task call, so nothing partial ships. +- **Artifacts are an intra-run handoff**: consume-then-delete at the point of consumption, gated to the half of the consumption whose failure would leave the artifact not yet redundant, best-effort, `retention-days: 1` on every upload as the backstop, and never a blanket delete of the run's artifact set, which destroys the diagnostics you need when the run fails. +- **No-op republish**: an unchanged version re-pushes nothing, the release-create step skips when the tag exists and is refreshed only on a dispatch, registries dedupe server-side (`--skip-duplicate`, `skip-existing: true`), and Docker alone always re-pushes by design. +- **A build failure blocks every publish target**: `github-release` needs every build and guards with `!failure() && !cancelled()` as the terminal registry pusher (Docker) does, and a package target's separate `publish-` job `needs:` the release-task call, so no build failure ships anything partial. A failed package push is outside that, since it runs after the release is cut. -The full catalog, each guarantee with the failure mode it prevents, is in `references/d-guarantees.md`. Auditing, tracing, and probing a repo's workflows is `references/test-methodology.md`. +A condensed catalog of `WORKFLOW.md` section 4 is in `references/d-guarantees.md`. A condensed guide to `WORKFLOW.md` section 5's audit, trace, and probe procedure is in `references/test-methodology.md`. ## After Any Workflow Edit diff --git a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/d-guarantees.md b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/d-guarantees.md index b32348b9..a49f42ce 100644 --- a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/d-guarantees.md +++ b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/d-guarantees.md @@ -1,15 +1,15 @@ # The D-Guarantees, Condensed -Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as input to output plus the failure mode it prevents. This is the condensed catalog for working from, and `WORKFLOW.md` keeps authority, so read the section there when a guarantee's exact wording decides a verdict. +Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as the output a conforming pipeline produces. In that section an item names an input only where the guarantee applies to a particular trigger or state, and names the failure it prevents only where the output does not already show it. An item naming neither still binds every repo whose shape its domain covers, and a workflow violating any applicable guarantee is not operational. This is the condensed catalog for working from, and `WORKFLOW.md` keeps authority: read the section there when a guarantee's exact wording decides a verdict, since a condensed item can be shorter than the one it condenses. ## D1: PR Fast-Feedback (Smoke) -- **D1.1** Only changed targets build: each target has a paths-filter entry, unchanged targets skip. Prevents a changed target slipping through unbuilt. -- **D1.2** A validation job always runs on any PR, and a non-.NET repo replaces it (never deletes it), re-pointing every `needs:` on it, the aggregator and `smoke-build` both. Prevents a PR merging with no validation, or a dangling `needs:` failing the workflow to load. -- **D1.3** Smoke never publishes and never uploads: full compile/lint/test, no pushes, every `upload-artifact` gated `!smoke`. Prevents a PR publishing and orphaned artifacts. -- **D1.4** Workflow-file changes are not smoke-built (the filter excludes `.github/workflows/**`), actionlint still validates them. -- **D1.5** One required aggregator gates merge: `needs:` the changes and validation jobs, passes on skipped smoke, blocks on failure or cancelled, and its name is ruleset-bound (job `name:` equals ruleset `context:`, renamed together). -- **D1.6** Coverage reports to Codecov for C# and Python repos with tests, best-effort so an outage never reds the gate, with a `codecov.yml` setting statuses informational and `.gitignore` excluding coverage output. +- **D1.1** Only changed targets build: each target has a paths-filter entry naming the paths it is built from, unchanged targets skip, and a change touching no target's paths marks nothing. A filter written as a negation of what must not build marks a docs-only change as a target change and fails this item. Prevents a changed target slipping through unbuilt. +- **D1.2** A validation job always runs on any PR: the caller's own job reaching the reusable validator, named `validate` in every shipped stub, which is the name the aggregator `needs:`. The validator's internal jobs are not addressable from a caller, and one of the hub's is itself called `validate`, so the matching name in a `needs:` list is always the caller's own job. It detects the tree rather than the language, so a non-.NET repo calls the same validator. A repo whose validation it cannot express replaces the call (never deletes it) and re-points the aggregator's `needs:`. `smoke-build` `needs:` the `changes` job, not the validation job. Prevents a PR merging with no validation, or a dangling `needs:` that stops the whole workflow from loading. +- **D1.3** Smoke never publishes and never uploads: full compile/lint/test, no pushes, every `upload-artifact` gated on smoke being false, `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings. Prevents a PR publishing and orphaned artifacts. +- **D1.4** A PR changing only `.github/workflows/**` is not smoke-built, since an inclusion list satisfying D1.1 matches no workflow path, and actionlint still validates them. +- **D1.5** One required aggregator gates merge: `if: always()`, `needs:` the validation job plus the `changes` and `smoke-build` jobs wherever the repo has a smoke build, passes on skipped smoke, blocks on failure or cancelled, and its name is ruleset-bound (job `name:` equals ruleset `context:`, renamed together). +- **D1.6** Coverage reports to Codecov for C# and Python repos with tests, a lint-only profile for that type excepted, best-effort so an outage never reds the gate, with a `codecov.yml` setting statuses informational and `.gitignore` excluding coverage output. ## D2: Validation at Entry @@ -29,16 +29,16 @@ Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as input to output ## D4: Release and Publish - **D4.1** Gated single-branch publish: a human merge never auto-publishes, the `plan` job decides once, publishes come from a code-affecting bot push to `main`, a dispatch of `main`/`develop`, or the main-only weekly Docker schedule. -- **D4.2** `target_commitish` is the built commit's SHA (NBGV `GitCommitId`), never a branch name and never `github.sha`. -- **D4.3** Every release is a tag plus source zip, README, and LICENSE, file targets attach `release-asset-*`, and a no-file-target caller passes `expect_release_assets: false` or the release-create step fails on unmatched files. -- **D4.4** No-op republish: an unchanged version re-pushes nothing, the release-create skips when the tag exists (refreshed only on `workflow_dispatch`), registries dedupe server-side, and Docker always re-pushes by design. -- **D4.5** A failed build blocks every publish target: `github-release` needs every build, the terminal registry pusher guards `!failure() && !cancelled()`, and a package target's separate `publish-` job `needs:` the release-task call, so nothing partial ships. +- **D4.2** `target_commitish` is the built commit's SHA (NBGV `GitCommitId`), never a branch name and never a separately re-resolved ref. +- **D4.3** Every release is a tag plus source zip, README, and LICENSE, `prerelease` equals `branch != default`, file targets attach `release-asset-*`, and a no-file-target caller (Docker-only, PyPI-only, source-only) passes `expect_release_assets: false` or the release-create step fails on unmatched files, a source-only one setting every `enable_*` input false with it. A NuGet caller is not one of those, since its leaf uploads a `release-asset-*` carrying the package. +- **D4.4** No-op republish on a schedule or push trigger: an unchanged version re-pushes nothing and the release-create skips when the tag exists, while a dispatch re-run refreshes it and runs the paired asset delete with it, registries dedupe server-side under `dotnet nuget push --skip-duplicate` and PyPI's `skip-existing: true`, and Docker always re-pushes by design. +- **D4.5** A failed build blocks every publish target: `github-release` needs every build and the terminal registry pusher (Docker) needs every other build, both guarding `!failure() && !cancelled()` so a disabled or unchanged target, skipped rather than failed, still lets the release be cut and the image pushed, and a package target's separate `publish-` job `needs:` the release-task call, so no build failure ships anything partial. A failed **package** push is outside that: the `publish-` job runs after the whole release task and so after `github-release`, and can leave a release and tag for a version the registry never received. The recovery is a re-dispatch rather than a cleanup, and it repairs that version only while the branch tip has not moved, since NBGV derives the version from git height, so a re-dispatch after the tip has moved publishes a further version and leaves the first release without its package. - **D4.6** A deploy check asserts which release and which environment answer, waiting for convergence to a bounded timeout, with an unreachable host reported distinctly from an HTTP status. ## D5: Resource Cleanup -- **D5.1** A cross-job transfer artifact is deleted at its point of consumption. An in-run intermediate may rely on the retention backstop. -- **D5.2** The delete runs under the same condition as its consumer, so a no-op re-run skips the release-asset delete while the `nuget-build-*` and `pypi-build-*` deletes still run. +- **D5.1** A cross-job transfer artifact is deleted by exact name or pattern at its point of consumption. An in-run intermediate may rely on the retention backstop. +- **D5.2** The delete runs exactly when the consumption happened: the same condition as a conditional consumer (the release create), and `if: ${{ !cancelled() && steps..outcome == 'success' }}` where the consumer is a push that always attempts, since a delete with no status-check function in its `if:` inherits `success()` and would skip on the failed push. So a no-op re-run that is not a dispatch skips the release-asset delete while the `nuget-build-*` and `pypi-build-*` deletes still run, and a dispatch re-run refreshes the release and runs the asset delete with it. - **D5.3** Cleanup is best-effort (`continue-on-error`, tolerate a failed listing, delete all matching ids). - **D5.4** Every `upload-artifact` sets `retention-days: 1`. - **D5.5** Never blanket-delete the run's artifacts, which destroys diagnostics and auto-emitted build records. @@ -56,7 +56,7 @@ Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as input to output - **D7.1** The publisher serializes: global ref-independent concurrency group, `cancel-in-progress: false`. - **D7.2** A reusable job declares `permissions:` only where every caller grants that scope at startup (the block is validated before `if:`), and otherwise declares none and runs under the calling job's grant, a callee's extra scope granted by the caller at the one entry point needing it. - **D7.3** Boolean inputs are declared in both trigger blocks and compared against both forms. -- **D7.4** Optional-dependency chaining allowlists `success`/`skipped` explicitly. +- **D7.4** Optional-dependency chaining allowlists `success`/`skipped` explicitly, beside a status-check function, since the implicit `success()` is false the moment any `needs:` job skipped. ## D8: Bots and Automation diff --git a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/test-methodology.md b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/test-methodology.md index ecd09487..dfda1191 100644 --- a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/test-methodology.md +++ b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/test-methodology.md @@ -4,7 +4,11 @@ The three escalating verification modes from `WORKFLOW.md` section 5, which keep ## 5A: Static Audit -Read the workflow files plus `version.json` and assert the structural fact behind each applicable D-guarantee, each pass, fail, or N/A with a `file:line` citation, asserting each input in the layer that declares it. The core sweep covers: the paths-filter's target coverage and `.github/workflows/**` exclusion, smoke gating on every upload, the aggregator's `needs:` and skip/fail handling, the entry validation jobs and the two-directional release gate, the single-branch NBGV classification and the three default-branch literals agreeing, `target_commitish` from `GitCommitId`, the consume-then-delete artifact lifecycle with `retention-days: 1` everywhere and no blanket delete, the `pattern:` handoff and `inputs.branch` config, the publisher's serialized concurrency, and the SHA pins. `WORKFLOW.md` 5A lists the per-type addenda (console runtime matrix, NuGet `--skip-duplicate`, the PyPI OIDC environment split, Docker `expect_release_assets` and cache shape, the static-site deploy gates), so apply only the ones the repo's types imply. +Read the workflow files plus `version.json` and assert the structural fact behind each applicable D-guarantee, each pass, fail, or N/A with a `file:line` citation, remembering the two layers and asserting each input in the file that declares it. `WORKFLOW.md` 5A carries the whole core list and the per-type addenda, and the sibling `d-guarantees.md` carries the guarantees each item answers to, so read this as an index into them rather than as the sweep itself. + +The core sweep reaches the paths-filter, naming each target's own build paths so a change touching none marks nothing. It reaches smoke gating on every upload. It reaches the aggregator's `needs:` and its skip and fail handling. It reaches the entry validation jobs and the two-directional release gate. It reaches the single-branch NBGV classification, with the gate's default-branch literal, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec` all naming the repo's actual default branch. It reaches `target_commitish` from `GitCommitId`. It reaches the consume-then-delete artifact lifecycle, with `retention-days: 1` everywhere and no blanket delete. It reaches the `pattern:` handoff and `inputs.branch` config. It reaches the publisher's serialized concurrency and the SHA pins. + +The per-type addenda cover .NET publish, NuGet, PyPI, Docker, and a static site deployed to a host, several assertions each. Apply only the ones the repo's types imply, and read them in `WORKFLOW.md` 5A rather than from this list. ## 5B: Trace Scenarios @@ -14,7 +18,7 @@ For each applicable scenario, evaluate every job's `if:`/`needs:` against the in - **S5/S6** a bot push to `main`: publishes only when code-affecting, and a human push never does. - **S7** a publish run builds the one trigger branch with the right classification and leaves no dangling artifacts. - **S8** a dispatch from a ref other than `main`/`develop` fails fast. -- **S9** a no-op re-run: release-create skipped, registries dedupe, PyPI build artifact still deleted, Docker still re-pushes. +- **S9** a no-op re-run on a schedule or push trigger: release-create skipped, registries dedupe, package build artifacts still deleted, Docker still re-pushes. A dispatch re-run refreshes the release instead. - **S10** branch and version classification disagree: the gate fails loud and everything downstream skips. - **S12/S13** a deploy dispatch: ref gate first, environment re-asserted, pointer flip separate, live check names the release, and a production deploy from a non-default ref fails before anything is written. diff --git a/.github/skills/operational-vs-release-workflow/references/release-publish-mechanics.md b/.github/skills/operational-vs-release-workflow/references/release-publish-mechanics.md index a95b7349..8bfd49a6 100644 --- a/.github/skills/operational-vs-release-workflow/references/release-publish-mechanics.md +++ b/.github/skills/operational-vs-release-workflow/references/release-publish-mechanics.md @@ -34,8 +34,9 @@ standardization baseline: `publish-release.yml` (single-branch publish plan), th task plus `github-release` job inside `build-release-task.yml`, `get-version-task.yml`, and the aggregator shape of `test-pull-request.yml`. Within `test-pull-request.yml`, only the `changes -> smoke-build -> check-workflow-status` aggregator -wiring and the ruleset-bound job name are verbatim orchestration, while the `unit-test` job and -the `dorny/paths-filter` entries are owned/per-target. The **build** layer is a hook: a composite +wiring and the ruleset-bound job name are verbatim orchestration, while the `dorny/paths-filter` +entries are owned/per-target. The validation job is a call to the reusable validator, whose own +jobs a caller cannot address. The **build** layer is a hook: a composite action at `.github/actions/build-` the hub-hosted `build-release-task.yml` reaches. The hub defaults require explicit project paths. A project needing more than a path override carries its own hook. @@ -53,7 +54,7 @@ looks tidier for 1:1 but forks the `github-release` download and breaks its verb **What a repo still curates** (by design, not a leak): which `enable_` inputs its caller stub sets, per the per-target subsetting rule above. `build-release-task.yml` is hub-hosted -(`docs/reusable-workflows.md` "Stage 4: The Release Chain and the Docker Core"), so its job graph +(the hub's `docs/reusable-workflows.md` "Stage 4: The Release Chain and the Docker Core"), so its job graph and its `github-release` job are the hub's, not a per-repo file a caller edits. A repo adopting the release chain carries only the caller stub in its own `publish-release.yml` and `test-pull-request.yml`, naming the hub task by pin and setting the `enable_*`, `docker_image`, @@ -116,9 +117,9 @@ version/tag, which is heavyweight but expected even for a non-.NET repo, and acc ## No-op republish guarantee -A weekly/dispatch publish where NBGV `SemVer2` is **unchanged** (no new commit since the last +A scheduled or push publish where NBGV `SemVer2` is **unchanged** (no new commit since the last publish) re-pushes **nothing** to GitHub Releases (the `github-release` job's `release-exists` -check skips the create step), NuGet (`dotnet nuget push --skip-duplicate`), or PyPI +check skips the create step, and a dispatch refreshes the release instead of skipping), NuGet (`dotnet nuget push --skip-duplicate`), or PyPI (`gh-action-pypi-publish` `skip-existing: true`), since all three key on the version string. **Docker always re-pushes** by design: it picks up upstream base-image refreshes (e.g. `ubuntu:rolling`) that aren't visible in the repo. Boundary: `version.json` has **no diff --git a/.github/skills/workflow-ci-contract/SKILL.md b/.github/skills/workflow-ci-contract/SKILL.md index 9eaf908f..9cf6432d 100644 --- a/.github/skills/workflow-ci-contract/SKILL.md +++ b/.github/skills/workflow-ci-contract/SKILL.md @@ -1,7 +1,7 @@ --- name: workflow-ci-contract description: >- - Governs the WORKFLOW.md CI/CD behavioral contract for every ptr727/ProjectTemplate fleet repo: the D1-D9 guarantees stated as the failure mode each prevents, the seam contract for release assets, the artifact lifecycle, NBGV versioning and classification, validate-at-entry, and the 5A/5B/5C test methodology with its per-type walkthroughs. Use this whenever writing or editing anything under .github/workflows/, adding or dropping a release target, auditing a repo's workflows, or reasoning about why a publish did or did not fire. This is the YAML half of the pipeline, and the operational-vs-release-workflow skill keeps the git half (branching, promotion, publish policy), so branch choice questions go there. Triggers even when the edit looks mechanical, such as bumping an action, renaming a job, or adding one upload step, because SHA pinning, the ruleset-bound aggregator name, smoke gating on uploads, and retention-days are each easy to break in a one-line diff that no smoke build exercises, since workflow-only changes are deliberately not smoke-built. WORKFLOW.md keeps authority, and GOVERNANCE.md wins where the two overlap. + Governs the WORKFLOW.md CI/CD behavioral contract for every ptr727/ProjectTemplate fleet repo: the D1-D9 guarantees, the seam contract for release assets, the artifact lifecycle, NBGV versioning and classification, validate-at-entry, and the 5A/5B/5C test methodology with its per-type walkthroughs. Use this whenever writing or editing anything under .github/workflows/, adding or dropping a release target, auditing a repo's workflows, or reasoning about why a publish did or did not fire. This is the YAML half of the pipeline, and the operational-vs-release-workflow skill keeps the git half (branching, promotion, publish policy), so branch choice questions go there. Triggers even when the edit looks mechanical, such as bumping an action, renaming a job, or adding one upload step, because SHA pinning, the ruleset-bound aggregator name, smoke gating on uploads, and retention-days are each easy to break in a one-line diff that no smoke build exercises, since workflow-only changes are deliberately not smoke-built. WORKFLOW.md keeps authority, and GOVERNANCE.md wins where the two overlap. --- # Workflow CI Contract @@ -32,15 +32,15 @@ description: >- ## The Core Behavioral Spine -- **PRs validate fast and never publish**: a paths-filter smoke-builds only changed targets, a type-appropriate validation job always runs, and one required aggregator gates the merge, treating skipped smoke as pass and blocking on failure or cancelled. Smoke does a full compile/lint/test but pushes nothing and uploads nothing, every `upload-artifact` gated `!smoke`. +- **PRs validate fast and never publish**: a paths-filter smoke-builds only changed targets, the caller's own job reaching the reusable validator, or the replacement it points its aggregator at, always runs, and one required aggregator gates the merge, treating skipped smoke as pass and blocking on failure or cancelled. Smoke does a full compile/lint/test but pushes nothing and uploads nothing, every `upload-artifact` gated on smoke being false, which is `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings. - **A human merge never auto-publishes**: a `plan` job decides once and every job gates on it. Publishes come from a code-affecting bot push to `main`, a manual dispatch of `main` or `develop`, or the main-only weekly Docker schedule. Each run builds the one trigger branch, `main` a clean `X.Y.Z`, anything else a prerelease `X.Y.Z-g`, with NBGV owning the patch from git height. The release tags the built commit's SHA (`GitCommitId`), never a branch name. - **Validate at entry**: cross-input and input-versus-derived-state invariants are asserted once in a dedicated entry job the downstream jobs `needs:`, failing fast with `::error::` before expensive work. The release gate checks branch-versus-prerelease in both directions, strips `+buildmetadata`, and on smoke skips the check while the job still succeeds. - **The seam contract**: a target contributes a release file by uploading `release-asset--`, and the release job collects by `pattern:` plus `merge-multiple:`, never `artifact-ids:`, canonical even for a single target. A repo with no file target passes `expect_release_assets: false` at the caller. -- **Artifacts are an intra-run handoff**: consume-then-delete at the point of consumption, gated to the consumer's condition, best-effort, `retention-days: 1` on every upload as the backstop, and never a blanket delete of the run's artifact set, which destroys the diagnostics you need when the run fails. -- **No-op republish**: an unchanged version re-pushes nothing, the release-create step skips when the tag exists, registries dedupe server-side (`--skip-duplicate`, `skip-existing: true`), and Docker alone always re-pushes by design. -- **A build failure blocks every publish target**: `github-release` needs every build, the terminal registry pusher guards with `!failure() && !cancelled()`, and a package target's separate `publish-` job `needs:` the release-task call, so nothing partial ships. +- **Artifacts are an intra-run handoff**: consume-then-delete at the point of consumption, gated to the half of the consumption whose failure would leave the artifact not yet redundant, best-effort, `retention-days: 1` on every upload as the backstop, and never a blanket delete of the run's artifact set, which destroys the diagnostics you need when the run fails. +- **No-op republish**: an unchanged version re-pushes nothing, the release-create step skips when the tag exists and is refreshed only on a dispatch, registries dedupe server-side (`--skip-duplicate`, `skip-existing: true`), and Docker alone always re-pushes by design. +- **A build failure blocks every publish target**: `github-release` needs every build and guards with `!failure() && !cancelled()` as the terminal registry pusher (Docker) does, and a package target's separate `publish-` job `needs:` the release-task call, so no build failure ships anything partial. A failed package push is outside that, since it runs after the release is cut. -The full catalog, each guarantee with the failure mode it prevents, is in `references/d-guarantees.md`. Auditing, tracing, and probing a repo's workflows is `references/test-methodology.md`. +A condensed catalog of `WORKFLOW.md` section 4 is in `references/d-guarantees.md`. A condensed guide to `WORKFLOW.md` section 5's audit, trace, and probe procedure is in `references/test-methodology.md`. ## After Any Workflow Edit diff --git a/.github/skills/workflow-ci-contract/references/d-guarantees.md b/.github/skills/workflow-ci-contract/references/d-guarantees.md index b32348b9..a49f42ce 100644 --- a/.github/skills/workflow-ci-contract/references/d-guarantees.md +++ b/.github/skills/workflow-ci-contract/references/d-guarantees.md @@ -1,15 +1,15 @@ # The D-Guarantees, Condensed -Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as input to output plus the failure mode it prevents. This is the condensed catalog for working from, and `WORKFLOW.md` keeps authority, so read the section there when a guarantee's exact wording decides a verdict. +Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as the output a conforming pipeline produces. In that section an item names an input only where the guarantee applies to a particular trigger or state, and names the failure it prevents only where the output does not already show it. An item naming neither still binds every repo whose shape its domain covers, and a workflow violating any applicable guarantee is not operational. This is the condensed catalog for working from, and `WORKFLOW.md` keeps authority: read the section there when a guarantee's exact wording decides a verdict, since a condensed item can be shorter than the one it condenses. ## D1: PR Fast-Feedback (Smoke) -- **D1.1** Only changed targets build: each target has a paths-filter entry, unchanged targets skip. Prevents a changed target slipping through unbuilt. -- **D1.2** A validation job always runs on any PR, and a non-.NET repo replaces it (never deletes it), re-pointing every `needs:` on it, the aggregator and `smoke-build` both. Prevents a PR merging with no validation, or a dangling `needs:` failing the workflow to load. -- **D1.3** Smoke never publishes and never uploads: full compile/lint/test, no pushes, every `upload-artifact` gated `!smoke`. Prevents a PR publishing and orphaned artifacts. -- **D1.4** Workflow-file changes are not smoke-built (the filter excludes `.github/workflows/**`), actionlint still validates them. -- **D1.5** One required aggregator gates merge: `needs:` the changes and validation jobs, passes on skipped smoke, blocks on failure or cancelled, and its name is ruleset-bound (job `name:` equals ruleset `context:`, renamed together). -- **D1.6** Coverage reports to Codecov for C# and Python repos with tests, best-effort so an outage never reds the gate, with a `codecov.yml` setting statuses informational and `.gitignore` excluding coverage output. +- **D1.1** Only changed targets build: each target has a paths-filter entry naming the paths it is built from, unchanged targets skip, and a change touching no target's paths marks nothing. A filter written as a negation of what must not build marks a docs-only change as a target change and fails this item. Prevents a changed target slipping through unbuilt. +- **D1.2** A validation job always runs on any PR: the caller's own job reaching the reusable validator, named `validate` in every shipped stub, which is the name the aggregator `needs:`. The validator's internal jobs are not addressable from a caller, and one of the hub's is itself called `validate`, so the matching name in a `needs:` list is always the caller's own job. It detects the tree rather than the language, so a non-.NET repo calls the same validator. A repo whose validation it cannot express replaces the call (never deletes it) and re-points the aggregator's `needs:`. `smoke-build` `needs:` the `changes` job, not the validation job. Prevents a PR merging with no validation, or a dangling `needs:` that stops the whole workflow from loading. +- **D1.3** Smoke never publishes and never uploads: full compile/lint/test, no pushes, every `upload-artifact` gated on smoke being false, `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings. Prevents a PR publishing and orphaned artifacts. +- **D1.4** A PR changing only `.github/workflows/**` is not smoke-built, since an inclusion list satisfying D1.1 matches no workflow path, and actionlint still validates them. +- **D1.5** One required aggregator gates merge: `if: always()`, `needs:` the validation job plus the `changes` and `smoke-build` jobs wherever the repo has a smoke build, passes on skipped smoke, blocks on failure or cancelled, and its name is ruleset-bound (job `name:` equals ruleset `context:`, renamed together). +- **D1.6** Coverage reports to Codecov for C# and Python repos with tests, a lint-only profile for that type excepted, best-effort so an outage never reds the gate, with a `codecov.yml` setting statuses informational and `.gitignore` excluding coverage output. ## D2: Validation at Entry @@ -29,16 +29,16 @@ Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as input to output ## D4: Release and Publish - **D4.1** Gated single-branch publish: a human merge never auto-publishes, the `plan` job decides once, publishes come from a code-affecting bot push to `main`, a dispatch of `main`/`develop`, or the main-only weekly Docker schedule. -- **D4.2** `target_commitish` is the built commit's SHA (NBGV `GitCommitId`), never a branch name and never `github.sha`. -- **D4.3** Every release is a tag plus source zip, README, and LICENSE, file targets attach `release-asset-*`, and a no-file-target caller passes `expect_release_assets: false` or the release-create step fails on unmatched files. -- **D4.4** No-op republish: an unchanged version re-pushes nothing, the release-create skips when the tag exists (refreshed only on `workflow_dispatch`), registries dedupe server-side, and Docker always re-pushes by design. -- **D4.5** A failed build blocks every publish target: `github-release` needs every build, the terminal registry pusher guards `!failure() && !cancelled()`, and a package target's separate `publish-` job `needs:` the release-task call, so nothing partial ships. +- **D4.2** `target_commitish` is the built commit's SHA (NBGV `GitCommitId`), never a branch name and never a separately re-resolved ref. +- **D4.3** Every release is a tag plus source zip, README, and LICENSE, `prerelease` equals `branch != default`, file targets attach `release-asset-*`, and a no-file-target caller (Docker-only, PyPI-only, source-only) passes `expect_release_assets: false` or the release-create step fails on unmatched files, a source-only one setting every `enable_*` input false with it. A NuGet caller is not one of those, since its leaf uploads a `release-asset-*` carrying the package. +- **D4.4** No-op republish on a schedule or push trigger: an unchanged version re-pushes nothing and the release-create skips when the tag exists, while a dispatch re-run refreshes it and runs the paired asset delete with it, registries dedupe server-side under `dotnet nuget push --skip-duplicate` and PyPI's `skip-existing: true`, and Docker always re-pushes by design. +- **D4.5** A failed build blocks every publish target: `github-release` needs every build and the terminal registry pusher (Docker) needs every other build, both guarding `!failure() && !cancelled()` so a disabled or unchanged target, skipped rather than failed, still lets the release be cut and the image pushed, and a package target's separate `publish-` job `needs:` the release-task call, so no build failure ships anything partial. A failed **package** push is outside that: the `publish-` job runs after the whole release task and so after `github-release`, and can leave a release and tag for a version the registry never received. The recovery is a re-dispatch rather than a cleanup, and it repairs that version only while the branch tip has not moved, since NBGV derives the version from git height, so a re-dispatch after the tip has moved publishes a further version and leaves the first release without its package. - **D4.6** A deploy check asserts which release and which environment answer, waiting for convergence to a bounded timeout, with an unreachable host reported distinctly from an HTTP status. ## D5: Resource Cleanup -- **D5.1** A cross-job transfer artifact is deleted at its point of consumption. An in-run intermediate may rely on the retention backstop. -- **D5.2** The delete runs under the same condition as its consumer, so a no-op re-run skips the release-asset delete while the `nuget-build-*` and `pypi-build-*` deletes still run. +- **D5.1** A cross-job transfer artifact is deleted by exact name or pattern at its point of consumption. An in-run intermediate may rely on the retention backstop. +- **D5.2** The delete runs exactly when the consumption happened: the same condition as a conditional consumer (the release create), and `if: ${{ !cancelled() && steps..outcome == 'success' }}` where the consumer is a push that always attempts, since a delete with no status-check function in its `if:` inherits `success()` and would skip on the failed push. So a no-op re-run that is not a dispatch skips the release-asset delete while the `nuget-build-*` and `pypi-build-*` deletes still run, and a dispatch re-run refreshes the release and runs the asset delete with it. - **D5.3** Cleanup is best-effort (`continue-on-error`, tolerate a failed listing, delete all matching ids). - **D5.4** Every `upload-artifact` sets `retention-days: 1`. - **D5.5** Never blanket-delete the run's artifacts, which destroys diagnostics and auto-emitted build records. @@ -56,7 +56,7 @@ Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as input to output - **D7.1** The publisher serializes: global ref-independent concurrency group, `cancel-in-progress: false`. - **D7.2** A reusable job declares `permissions:` only where every caller grants that scope at startup (the block is validated before `if:`), and otherwise declares none and runs under the calling job's grant, a callee's extra scope granted by the caller at the one entry point needing it. - **D7.3** Boolean inputs are declared in both trigger blocks and compared against both forms. -- **D7.4** Optional-dependency chaining allowlists `success`/`skipped` explicitly. +- **D7.4** Optional-dependency chaining allowlists `success`/`skipped` explicitly, beside a status-check function, since the implicit `success()` is false the moment any `needs:` job skipped. ## D8: Bots and Automation diff --git a/.github/skills/workflow-ci-contract/references/test-methodology.md b/.github/skills/workflow-ci-contract/references/test-methodology.md index ecd09487..dfda1191 100644 --- a/.github/skills/workflow-ci-contract/references/test-methodology.md +++ b/.github/skills/workflow-ci-contract/references/test-methodology.md @@ -4,7 +4,11 @@ The three escalating verification modes from `WORKFLOW.md` section 5, which keep ## 5A: Static Audit -Read the workflow files plus `version.json` and assert the structural fact behind each applicable D-guarantee, each pass, fail, or N/A with a `file:line` citation, asserting each input in the layer that declares it. The core sweep covers: the paths-filter's target coverage and `.github/workflows/**` exclusion, smoke gating on every upload, the aggregator's `needs:` and skip/fail handling, the entry validation jobs and the two-directional release gate, the single-branch NBGV classification and the three default-branch literals agreeing, `target_commitish` from `GitCommitId`, the consume-then-delete artifact lifecycle with `retention-days: 1` everywhere and no blanket delete, the `pattern:` handoff and `inputs.branch` config, the publisher's serialized concurrency, and the SHA pins. `WORKFLOW.md` 5A lists the per-type addenda (console runtime matrix, NuGet `--skip-duplicate`, the PyPI OIDC environment split, Docker `expect_release_assets` and cache shape, the static-site deploy gates), so apply only the ones the repo's types imply. +Read the workflow files plus `version.json` and assert the structural fact behind each applicable D-guarantee, each pass, fail, or N/A with a `file:line` citation, remembering the two layers and asserting each input in the file that declares it. `WORKFLOW.md` 5A carries the whole core list and the per-type addenda, and the sibling `d-guarantees.md` carries the guarantees each item answers to, so read this as an index into them rather than as the sweep itself. + +The core sweep reaches the paths-filter, naming each target's own build paths so a change touching none marks nothing. It reaches smoke gating on every upload. It reaches the aggregator's `needs:` and its skip and fail handling. It reaches the entry validation jobs and the two-directional release gate. It reaches the single-branch NBGV classification, with the gate's default-branch literal, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec` all naming the repo's actual default branch. It reaches `target_commitish` from `GitCommitId`. It reaches the consume-then-delete artifact lifecycle, with `retention-days: 1` everywhere and no blanket delete. It reaches the `pattern:` handoff and `inputs.branch` config. It reaches the publisher's serialized concurrency and the SHA pins. + +The per-type addenda cover .NET publish, NuGet, PyPI, Docker, and a static site deployed to a host, several assertions each. Apply only the ones the repo's types imply, and read them in `WORKFLOW.md` 5A rather than from this list. ## 5B: Trace Scenarios @@ -14,7 +18,7 @@ For each applicable scenario, evaluate every job's `if:`/`needs:` against the in - **S5/S6** a bot push to `main`: publishes only when code-affecting, and a human push never does. - **S7** a publish run builds the one trigger branch with the right classification and leaves no dangling artifacts. - **S8** a dispatch from a ref other than `main`/`develop` fails fast. -- **S9** a no-op re-run: release-create skipped, registries dedupe, PyPI build artifact still deleted, Docker still re-pushes. +- **S9** a no-op re-run on a schedule or push trigger: release-create skipped, registries dedupe, package build artifacts still deleted, Docker still re-pushes. A dispatch re-run refreshes the release instead. - **S10** branch and version classification disagree: the gate fails loud and everything downstream skips. - **S12/S13** a deploy dispatch: ref gate first, environment re-asserted, pointer flip separate, live check names the release, and a production deploy from a non-default ref fails before anything is written. diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml index 3a6fa37d..765f752e 100644 --- a/.github/workflows/build-release-task.yml +++ b/.github/workflows/build-release-task.yml @@ -4,7 +4,7 @@ name: Build project release task # NBGV runs once in get-version and threads the version to every target. # The validate-release job gates branch<->version consistency once before any build or publish. # The github-release job is verbatim in shape: pattern download, exists gate, target_commitish, consume-then-delete. -# The expect_release_assets input collapses the fleet's three no-asset release shapes (Docker-only, PyPI-only, NuGet symbols-only) into one flag. +# The expect_release_assets input collapses the fleet's three no-asset release shapes (Docker-only, PyPI-only, source-only) into one flag. # # Each build target uses a caller hook or the hub default for that target. # The Docker target calls the hub's reusable Docker task at this workflow's commit. @@ -57,7 +57,8 @@ on: required: false type: boolean default: true - # Set false for a repo that produces no release-asset-* files, such as a Docker-only or PyPI-only one. + # Set false for a repo that produces no release-asset-* files, such as a Docker-only, PyPI-only, or source-only one. + # A NuGet caller keeps the default, since the nuget-build hook uploads a release-asset--nuget carrying the package. # The release is then just the tag + source zip + README + LICENSE. # The artifact download is skipped and the unmatched-files guard relaxes. expect_release_assets: diff --git a/GOVERNANCE.md b/GOVERNANCE.md index de286e7f..49a134be 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -225,15 +225,15 @@ This section and [`WORKFLOW.md`](./WORKFLOW.md) keep the full rules, this sectio - **Action pinning**: pin **every** action, first-party (`actions/*`) and third-party alike, to a commit SHA with a trailing `# vX.Y.Z` comment, so Renovate / Dependabot can still bump it but a tag swap can't change the executed code. Use `# vX` (major-only) only when the upstream's floating major tag doesn't correspond to a specific patch/minor release SHA, since pinning to the floating-tag SHA still gives the SHA guarantee, the version comment just records the major line. Documented exception (no SHA pin at all): `dotnet/nbgv` is consumed via `@master` because the upstream tag stream lags `master` substantially and Dependabot's tag-tracking would propose a downgrade. **This applies to repo-owned build-layer leaves too**, since a leaf owning its build specifics is not a reason to use floating tags, and Dependabot still bumps SHA pins (updating the SHA + version comment). - **Filename**: reusable workflows (those with `on: workflow_call`) end in `-task.yml`. Entry-point workflows (`on: push` / `pull_request` / `schedule` / `workflow_dispatch`) do NOT use the `-task` suffix. They end with what they do: `-pull-request.yml`, `-release.yml`, etc. The suffix carries semantic meaning: a `-task.yml` file is meant to be `uses:`-d, never triggered directly. - **Workflow `name:`** (the top-level `name:` field): reusable workflow names end in **"task"** (e.g. `Build project release task`), and entry-point workflow names end in **"action"** (e.g. `Publish project release action`, `Test pull request action`). The displayed action name in the GitHub Actions UI tells you at a glance whether you're looking at an orchestrator or a callee. -- **Job and step `name:` suffixes**: every job's `name:` ends in **"job"** and every step's `name:` ends in **"step"**, including the PR-gate aggregator, whose `name:` is a required-status-check `context:` in a branch ruleset (`Check pull request workflow status job` in `test-pull-request.yml`). A ruleset-bound job's `name:` and its ruleset `context:` are the **same string**: rename them **together**, updating the live ruleset and the hub's `repo-config/` payloads in lockstep with the job `name:`, never one without the other, or required-status-check enforcement silently breaks. There is no un-suffixed exception. +- **Job and step `name:` suffixes**: every job's `name:` ends in **"job"** and every step's `name:` ends in **"step"**, including the PR-gate aggregator, whose `name:` is a required-status-check `context:` in a branch ruleset (`Check pull request workflow status job` in `test-pull-request.yml`). A ruleset-bound job's `name:` and its ruleset `context:` are the **same string**: rename them **together**, updating in lockstep with the job `name:` every surface whose staleness breaks enforcement, never one without the others, or required-status-check enforcement silently breaks. Those are the live ruleset, the hub's `repo-config/` payloads, the hub's `spec/files.json` `requiredCheckName`, and each adopter-facing stub, the one in the hub's `catalog/` and the release-with-smoke shape in the hub's `docs/reusable-workflows.md` alike. Prose naming the old string elsewhere goes stale rather than breaking, and follows behind. There is no un-suffixed exception. - **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. **Documented exceptions** (both record the rationale inline in their header comment): (1) [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) keys the group on the **PR number** (`-${{ github.event.pull_request.number }}` rather than `-${{ github.ref }}`, which under `pull_request_target` is the base branch and would serialize every bot PR against it), and uses `cancel-in-progress: false` because the merge-bot's job model (enable-auto-merge on opened, disable-auto-merge on maintainer-pushed synchronize, with method dispatched by base) requires each event to run to completion in arrival order, because cancellation would leave auto-merge in an inconsistent state. (2) `.github/workflows/publish-release.yml` uses both a **global, ref-independent group** (`group: ${{ github.workflow }}`, dropping the usual `-${{ github.ref }}`) and `cancel-in-progress: false`. It publishes shared ref-independent artifacts (both branches' Docker tags/caches and GitHub releases) on schedule/dispatch regardless of the triggering ref, so a ref-scoped group would let a scheduled run (ref `main`) and a manual dispatch (ref `develop`) run concurrently and double-push, and cancelling a publish mid-flight can leave a partially pushed tag set or a half-created release. The global group + queueing serializes every publish run to completion. - **Shells**: every bash surface, a multi-line `run:` block and every committed `.sh` script alike, starts with `set -Eeuo pipefail`: fail fast, fail on undefined vars, fail on a failed pipe segment, and let an `ERR` trap inherit into functions, subshells, and command substitutions (`-E`). The `-E` is defense in depth: the fleet ships no `ERR` trap today, so a script that later adds one inherits the behavior instead of silently losing it. - **Conditionals**: multi-line `if:` uses folded scalar `if: >-` so YAML preserves whitespace correctly. Literal block (`if: |`) is wrong because it embeds newlines inside the boolean expression. - **Boolean inputs**: workflows triggered both via `workflow_call` and `workflow_dispatch` must declare each boolean input in *both* trigger blocks, since one definition does not propagate to the other. `workflow_call` delivers booleans as actual booleans, and `workflow_dispatch` delivers them as the *strings* `"true"`/`"false"`. Any `if:` consuming a boolean input must compare against both forms: `if: ${{ inputs.foo == true || inputs.foo == 'true' }}`. - **Validate input/state consistency at entry, fail fast**: when a workflow's inputs must satisfy a cross-input or input-versus-derived-state invariant (e.g. the release branch must match the computed version's prerelease status, or two inputs are mutually exclusive), assert it **once** in a dedicated entry validation step/job that the downstream jobs `needs:`, before any expensive build or publish work, not as partial checks scattered deep in later jobs. One gate that fails fast with a clear `::error::` beats a late or one-directional check. Examples: `build-release-task.yml`'s `validate-release` job (branch-versus-prerelease, both directions) and `publish-docker-readme-task.yml`'s "Validate inputs step". - **Reusable workflows**: job-level `permissions:` are validated *before* the `if:` evaluates, so even a skipped job needs valid permissions declared. A `release` job with `permissions: contents: write` and `if: ${{ inputs.publish }}` will still cause `startup_failure` on a caller that doesn't grant `contents: write`. So declare an inner block only where **every** caller grants that scope at startup, and otherwise omit it and run under the calling job's grant, declaring the scope at the call site. -- **Allowlist `success` and `skipped` explicitly** when chaining jobs across optional dependencies, since `!= 'failure'` lets `cancelled` through (timeout, runner failure, manual cancel). Use `(needs.X.result == 'success' || needs.X.result == 'skipped')`. -- **Artifact retention**: workflow artifacts are an intra-run handoff only, with durable copies living on the GitHub release rather than in workflow artifacts, so they must not survive the run and accumulate against the small account-wide artifact-storage quota. **Clean up each transfer artifact surgically at its point of consumption**: the job that downloads it deletes it by exact name/pattern right after consuming it, under the **same condition as the consuming step**, so a no-op re-run that skips the consumer skips the delete with it and leaves the freshly built assets alone (the `github-release` job deletes `release-asset--*` after attaching them to the release, and a package repo's `publish-release.yml` deletes `nuget-build-` or `pypi-build-` in the `publish-` job that consumed it). Deletion needs `actions: write` granted on that job, and for a reusable callee (e.g. `github-release` inside `build-release-task.yml`) the **caller** grants it (`publish-release.yml`'s `publish` job does). **Never blanket-delete the run's artifacts** (`gh api .../artifacts --jq '.artifacts[].id'`). That also destroys diagnostic/log artifacts and the build-records actions emit automatically (`docker/build-push-action`'s `.dockerbuild`), which are exactly what you need to debug a failed run. Set `retention-days: 1` on **every** explicit `upload-artifact`: it is the failure-path backstop, since a job that dies before its consumer runs leaves its artifact to be reaped within a day, so no separate terminal cleanup job is needed. A repo customizing these jobs must preserve the consume-then-delete shape. +- **Allowlist `success` and `skipped` explicitly** when chaining jobs across optional dependencies, since `!= 'failure'` lets `cancelled` through (timeout, runner failure, manual cancel). Use `(needs.X.result == 'success' || needs.X.result == 'skipped')`, and pair it with a status-check function such as `always()` or `!failure() && !cancelled()`. An `if:` carrying no such function has `success()` applied implicitly, and that implicit `success()` is false the moment any `needs:` job skipped, which is the case the allowlist exists to admit. +- **Artifact retention**: workflow artifacts are an intra-run handoff only, with durable copies living on the GitHub release or the registry rather than in workflow artifacts, so they must not survive the run and accumulate against the small account-wide artifact-storage quota. **Clean up each transfer artifact surgically at its point of consumption**: the job that downloads it deletes it by exact name/pattern right after consuming it, under the **condition that made it redundant**, which is the half of the consumption whose failure would mean it is not redundant yet. Where the consuming step is conditional, that condition is the consumer's: the `github-release` job deletes `release-asset--*` under the release-create step's own condition, narrowed by `inputs.expect_release_assets`, so a no-op re-run that skips the create skips the delete with it and leaves the freshly built assets alone. Where the consuming step always attempts once its job runs, the condition is the download's: a package repo's `publish-release.yml` deletes `nuget-build-` or `pypi-build-` in the `publish-` job under `if: ${{ !cancelled() && steps..outcome == 'success' }}`, because the artifact is redundant once it has been downloaded and the release cut, whether or not the push that followed succeeded. A delete left to the implicit `success()` would skip on exactly that failed push, and the `!cancelled()` suppresses that implicit `success()` the way any status-check function does. That implicit `success()` is the same mechanism the optional-dependency bullet above names, reached there by a skipped `needs:` job and here by a failed prior step. Deletion needs `actions: write` granted on that job, and for a reusable callee (e.g. `github-release` inside `build-release-task.yml`) the **caller** grants it (`publish-release.yml`'s `publish` job does). **Never blanket-delete the run's artifacts** (`gh api .../artifacts --jq '.artifacts[].id'`). That also destroys diagnostic/log artifacts and the build-records actions emit automatically (`docker/build-push-action`'s `.dockerbuild`), which are exactly what you need to debug a failed run. Set `retention-days: 1` on **every** explicit `upload-artifact`: it is the failure-path backstop, since a job that dies before its consumer runs leaves its artifact to be reaped within a day, so no separate terminal cleanup job is needed. A repo customizing these jobs must preserve the consume-then-delete shape. - **Docker layer cache**: cache to/from a registry tag (`type=registry`, e.g. `buildcache-` on Docker Hub), not the GitHub Actions cache (`type=gha`), to keep large image layers off the 10 GB Actions cache. A **multi-image** repo uses a **per-image** buildcache tag (`:buildcache-` for each image, plus the base image's own tag and inline cache). It does not fall back to `type=gha` for the extra images. - **Tag pinning on releases**: when using `softprops/action-gh-release` (or any tag-creating action), pass `target_commitish` explicitly, because without it GitHub's REST API defaults the new tag to the repository's default branch instead of the commit that built the artifact. Pin it to the **exact built commit's SHA** (the publisher uses NBGV's `GitCommitId` output), not `github.sha` (which may differ from the exact commit NBGV versioned) and not a branch name (a moving ref that a mid-run commit could advance past the built tree). diff --git a/README.md b/README.md index 58fb1eda..c146dd7b 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,7 @@ A human-readable index of the rules agents enforce, implement, and audit. The au - Publish via OIDC Trusted Publishing, never a stored API key. - Keep the push in a separate publish job in the repo's own publisher, never in a build leaf and never in a reusable workflow another repository hosts. Trusted publishing validates the OIDC token's `job_workflow_ref` claim against the repository owning the package, so a push from elsewhere is rejected at the token exchange, and the split is also what keeps `id-token: write` at one entry point. +- Keep the registry's trusted-publishing policy pointed at that publisher's own workflow file, and confirm it before the first release after any change that moves the push. A smoke build never reaches the token exchange, so a stale policy surfaces only on a real release. ### If a Docker Image diff --git a/WORKFLOW.md b/WORKFLOW.md index 76ebc514..3a58b3d7 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -12,7 +12,7 @@ Given this document, an agent must be able to do three things to any project: > **Canonical scope.** This document is authoritative for the workflow contract and test methodology (sections 3 to 6). The conventions in section 2 and the release policy also live in `GOVERNANCE.md` ("Workflow YAML Conventions" and "Release Model"), which is authoritative where the two overlap. Section 2 restates them so this file reads on its own. On any conflict in that overlap, `GOVERNANCE.md` wins. -The guarantees are distilled from failures observed in practice and stated as the **failure-mode each prevents**, so the document stays portable to any project. +The guarantees are distilled from failures observed in practice. Section 4's preamble states how each item is written. ## 1. Purpose and How to Use This Document @@ -36,7 +36,7 @@ Prescriptive style/legibility rules. Cheap to check, necessary but not sufficien - **Conditionals.** Multi-line `if:` uses the folded scalar `if: >-`. - **Boolean inputs.** A boolean used by both `workflow_call` and `workflow_dispatch` is declared in **both** trigger blocks, and `workflow_dispatch` delivers the **string** `"true"`/`"false"`, so any `if:` compares both forms: `${{ inputs.foo == true || inputs.foo == 'true' }}`. - **Reusable-workflow permissions.** Job-level `permissions:` are validated **before** `if:`, so even a skipped job needs valid permissions. Grant least privilege. A reusable callee's extra scope (e.g. `actions: write` for cleanup) is granted by the **caller**. -- **Allowlist `success` and `skipped` explicitly** across optional dependencies (`!= 'failure'` lets `cancelled` through). +- **Allowlist `success` and `skipped` explicitly** across optional dependencies (`!= 'failure'` lets `cancelled` through), and pair it with a status-check function (D7.4). - **Docker layer cache.** Cache to/from a registry tag (`type=registry`), never `type=gha`. - **Line endings.** Workflow YAML is LF (Actions and Dependabot rewrite it that way). Other files follow `.editorconfig`, and committed JSON state files follow the repo's JSON rule. Preserve endings on every edit. @@ -103,7 +103,7 @@ When a workflow's inputs carry a cross-input or input-versus-derived-state invar ### Resource Lifecycle -Workflow artifacts are an **intra-run handoff** only. Durable copies live on the release/registry. The rule: a transfer artifact handed **between jobs** is deleted by exact name/pattern **at its point of consumption**, the delete is **gated to the same condition as the consumer**, and it is **best-effort**. **Every** `upload-artifact` sets `retention-days: 1` as the universal failure-path backstop, so no terminal blanket-delete job is needed. An intermediate consumed only within the same run may rely on the retention backstop alone. The run is **never** blanket-deleted (`.artifacts[].id`). See D5. +Workflow artifacts are an **intra-run handoff** only. Durable copies live on the release/registry. The rule: a transfer artifact handed **between jobs** is deleted by exact name/pattern **at its point of consumption**, the delete is **gated to the half of the consumption whose failure would leave it not yet redundant** (D5.2 names the two halves), and it is **best-effort**. **Every** `upload-artifact` sets `retention-days: 1` as the universal failure-path backstop, so no terminal blanket-delete job is needed. An intermediate consumed only within the same run may rely on the retention backstop alone. The run is **never** blanket-deleted (`.artifacts[].id`). See D5. ### Fast PR Feedback @@ -145,16 +145,16 @@ Pick each output's path by **where the artifact goes**: ## 4. Behavioral Contract: Expected Outcomes -The required behaviors, organized by domain. Each is a **MUST**, stated as input -> output plus the failure-mode it prevents. A workflow that violates any *applicable* guarantee is **not operational**. +The required behaviors, organized by domain. Each is a **MUST**, stated as the output a conforming pipeline produces. An item carries an `Input:` only where the guarantee applies to a particular trigger or state rather than to every run, and a *Prevents:* clause only where the failure it rules out is not evident from the output itself. An item carrying neither still binds every repo whose shape its domain covers. A workflow that violates any *applicable* guarantee is **not operational**. ### D1 - PR Fast-Feedback (Smoke) -- **D1.1 Only changed targets build.** Input: a PR touching some targets. Output: the paths-filter marks exactly those targets and only their smoke builds run. Unchanged targets skip. A repo's own targets MUST each have a filter entry (so a touched target is never silently skipped). *Prevents: rebuilding everything, and a changed target slipping through unbuilt.* -- **D1.2 A validation job always runs.** Input: any PR. Output: a type-appropriate validation job runs unconditionally and the aggregator `needs:` it. In a .NET repo this is the `unit-test` job (format/style/test). A non-.NET repo **replaces** it (not deletes) with its own validator (lint, schema-check) and re-points **every** `needs:` on it (both the aggregator and `smoke-build`, which `needs:` the validation job by name) to the replacement. *Prevents: a PR merging with no validation, or a dangling `needs:` that stops the whole workflow from loading.* -- **D1.3 Smoke never publishes and never uploads.** Input: `smoke: true`. Output: full compile/lint/test, but no registry/image push, no release, and **no** artifact uploads (every `upload-artifact`, including any aggregation job, is gated `!smoke`). *Prevents: a PR publishing, and orphaned artifacts churning the storage quota.* -- **D1.4 Workflow-file changes are not smoke-built.** Input: a PR changing only `.github/workflows/**`. Output: the paths-filter excludes workflow files, so smoke-build skips. *Implication: a workflow-only change is not smoke-built, but actionlint still validates it in CI.* -- **D1.5 One required aggregator gates merge.** Input: any PR. Output: a single aggregator job must **succeed**, `needs:` the changes job and the validation job, treat a **skipped** smoke build as pass, and **block** on `failure`/`cancelled`. Its name is ruleset-bound: the job `name:` and the ruleset `context:` are the same string and MUST be renamed together, never independently. *Prevents: a paths-filter error letting a target-changing PR merge unbuilt.* -- **D1.6 Coverage is reported to Codecov (C# and Python).** Input: a C# or Python repo's validation/test job. Output: tests run with coverage collection (`dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage` or `pytest --cov-report=xml`) and a `codecov/codecov-action` step uploads it, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). `CODECOV_TOKEN` lives in the repo's **actions** and **dependabot** secret stores, the second because a run triggered by a Dependabot PR reads the Dependabot store and the upload would otherwise skip silently on every bot PR, and the caller maps it to the reusable validator by name (`secrets:` with `CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}`), the way every hub task's declared secrets are passed. Required for **every** C# and Python repo that has tests. That C# invocation runs under **Microsoft.Testing.Platform**, which an MTP-based test project on the .NET 10 SDK and later requires, since running one through the VSTest target fails outright. A repo whose test project is MTP-based, in practice any repo on xunit.v3 4.0.0 or later, therefore also ships a root **`global.json`** declaring `{"test": {"runner": "Microsoft.Testing.Platform"}}`, references **`Microsoft.Testing.Extensions.CodeCoverage`** at **18.9.0 or later** in place of `coverlet.collector`, whose VSTest data collector MTP ignores without failing, and drops `xunit.runner.visualstudio`, the VSTest adapter MTP replaces. A repo whose test project is not yet MTP-based keeps the VSTest collector and its existing pin on the reusable validator, and that lagging state is a migration still owed rather than drift, until its own bump makes the project MTP-based and forces the move. The version floor is load-bearing rather than cautionary. Below 18.1.0 the extension is built against Microsoft.Testing.Platform 1.x, and an 18.0.x resolution, which is what a `>= 18.0.0` range picks, throws a `TypeLoadException` against the 2.x platform xunit.v3 4.0.0 carries, runs zero tests, and still writes a well-formed Cobertura file reporting full coverage, so only the non-zero exit says the run reported nothing. 18.9.0 is the first release on Microsoft.Testing.Platform 2.3.x, where every test project writes into the one shared `--results-directory` the invocation names rather than resolving that relative path per project. Two details of the invocation are equally load-bearing, and neither failure reds the job on its own. `--coverage-output` stays unset, because pinning one filename gives every test project in the solution the same path and a repo with more than one then keeps only whichever ran last. Leaving it unset produces the default name `.cobertura.xml`, which `codecov-cli`'s own file finder does not match. The validator therefore prefixes each report to `coverage-.cobertura.xml` before the upload step reads the directory. Where this guarantee does not apply (a `lint-only` profile for that type, per the hub's `registry/repos.json`), the hub's `spec/secrets.json` `typeMechanisms` mapping is not claimed for that repo, and the absence is not drift. The repo also ships a **`codecov.yml`** that sets the project and patch statuses to **`informational: true`** so a coverage delta never gates a PR (a distinct knob from `fail_ci_if_error`, which only guards the upload step), and excludes intentionally-untested, non-shipped code (an example/demo or benchmark project) from the coverage denominator via `ignore`. A repo may override this to enforce a coverage threshold where its quality bar requires it. Coverage output is a build artifact, so `.gitignore` excludes it (e.g. `coverage/` and `*.cobertura.xml`, with `.gitignore` the full source of truth) so a blanket `git add -A` won't stage the untracked output. *Prevents: coverage silently going unreported, a test project stranded on a runner the current SDK refuses, a stale and unused token, a coverage regression blocking an unrelated PR, and a coverage artifact committed by a blanket add.* +- **D1.1 Only changed targets build.** Input: a PR touching some targets. Output: the paths-filter marks exactly those targets and only their smoke builds run. Unchanged targets skip. A repo's own targets MUST each have a filter entry (so a touched target is never silently skipped), and that entry lists paths rather than negating them, so a change matching no entry marks nothing and every smoke build skips. A filter written the other way round, as a negation of the paths that must not build, marks a docs-only change as a target change: it satisfies D1.4 and violates this item. *Prevents: rebuilding everything, and a changed target slipping through unbuilt.* +- **D1.2 A validation job always runs.** Input: any PR. Output: a validation job runs unconditionally and the aggregator `needs:` it. That job is the caller's own job reaching the reusable validator, named `validate` in every shipped stub, and that name is what the aggregator's `needs:` carries. The validator's internal jobs (`lint`, `unit-test` and `validate` in the hub's `validate-task.yml`) are not addressable from a caller, so a `validate` in a caller's `needs:` list always names the caller's own job rather than the validator's internal one of the same name. The validator detects the tree rather than the repo's language, running the doc and repo gates everywhere and the `dotnet test` or `uv run pytest` path only where that tree is present, so a non-.NET repo calls the same one rather than replacing it. A repo whose validation it cannot express **replaces** the call (not deletes it) with its own validator and re-points the aggregator's `needs:` to the replacement. `smoke-build` `needs:` the `changes` job rather than the validation job, so no second `needs:` moves with it. *Prevents: a PR merging with no validation, or a dangling `needs:` that stops the whole workflow from loading.* +- **D1.3 Smoke never publishes and never uploads.** Input: `smoke: true`. Output: full compile/lint/test, but no registry/image push, no release, and **no** artifact uploads (every `upload-artifact`, including any aggregation job, is gated on smoke being false, written `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings). *Prevents: a PR publishing, and orphaned artifacts churning the storage quota.* +- **D1.4 Workflow-file changes are not smoke-built.** Input: a PR changing only `.github/workflows/**`. Output: the paths-filter marks no target, so smoke-build skips. An inclusion list satisfying D1.1 reaches this by leaving workflow paths out of every target's entry. *Implication: a workflow-only change is not smoke-built, but actionlint still validates it in CI.* +- **D1.5 One required aggregator gates merge.** Input: any PR. Output: a single aggregator job must **succeed**, run under `if: always()` so a failed or skipped dependency cannot skip the gate itself, `needs:` the validation job, and the `changes` and `smoke-build` jobs too wherever the repo has a smoke build, treat a **skipped** smoke build as pass, and **block** on `failure`/`cancelled`. Its name is ruleset-bound: the job `name:` and the ruleset `context:` are the same string and MUST be renamed together, never independently. *Prevents: a paths-filter error letting a target-changing PR merge unbuilt.* +- **D1.6 Coverage is reported to Codecov (C# and Python).** Input: a C# or Python repo's validation/test job. Output: tests run with coverage collection (`dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage` or `pytest --cov-report=xml`) and a `codecov/codecov-action` step uploads it, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). `CODECOV_TOKEN` lives in the repo's **actions** and **dependabot** secret stores, the second because a run triggered by a Dependabot PR reads the Dependabot store and the upload would otherwise skip silently on every bot PR, and a caller passing it names it (`secrets:` with `CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}`), the way every hub task's declared secrets are passed, since `secrets: inherit` is documented for a caller in the same organization or enterprise and this fleet is a personal account, so a cross-repository call names each secret it passes. A call by local path stays inside one repository and may inherit instead. The publisher stub's validation job names the secret and every pull request stub's validation job passes no `secrets:` key at all, so a repo whose coverage must reach Codecov from its pull requests adds the mapping there itself. Required for **every** C# and Python repo that has tests. That C# invocation runs under **Microsoft.Testing.Platform**, which an MTP-based test project on the .NET 10 SDK and later requires, since running one through the VSTest target fails outright. A repo whose test project is MTP-based, in practice any repo on xunit.v3 4.0.0 or later, therefore also ships a root **`global.json`** declaring `{"test": {"runner": "Microsoft.Testing.Platform"}}`, references **`Microsoft.Testing.Extensions.CodeCoverage`** at **18.9.0 or later** in place of `coverlet.collector`, whose VSTest data collector MTP ignores without failing, and drops `xunit.runner.visualstudio`, the VSTest adapter MTP replaces. A repo whose test project is not yet MTP-based keeps the VSTest collector and its existing pin on the reusable validator, and that lagging state is a migration still owed rather than drift, until its own bump makes the project MTP-based and forces the move. The version floor is load-bearing rather than cautionary. Below 18.1.0 the extension is built against Microsoft.Testing.Platform 1.x, and an 18.0.x resolution, which is what a `>= 18.0.0` range picks, throws a `TypeLoadException` against the 2.x platform xunit.v3 4.0.0 carries, runs zero tests, and still writes a well-formed Cobertura file reporting full coverage, so only the non-zero exit says the run reported nothing. 18.9.0 is the first release on Microsoft.Testing.Platform 2.3.x, where every test project writes into the one shared `--results-directory` the invocation names rather than resolving that relative path per project. Two details of the invocation are equally load-bearing, and neither failure reds the job on its own. `--coverage-output` stays unset, because pinning one filename gives every test project in the solution the same path and a repo with more than one then keeps only whichever ran last. Leaving it unset produces the default name `.cobertura.xml`, which `codecov-cli`'s own file finder does not match. The validator therefore prefixes each report to `coverage-.cobertura.xml` before the upload step reads the directory. Where this guarantee does not apply (a `lint-only` profile for that type, per the hub's `registry/repos.json`), the hub's `spec/secrets.json` `typeMechanisms` mapping is not claimed for that repo, and the absence is not drift. The repo also ships a **`codecov.yml`** that sets the project and patch statuses to **`informational: true`** so a coverage delta never gates a PR (a distinct knob from `fail_ci_if_error`, which only guards the upload step), and excludes intentionally-untested, non-shipped code (an example/demo or benchmark project) from the coverage denominator via `ignore`. A repo may override this to enforce a coverage threshold where its quality bar requires it. Coverage output is a build artifact, so `.gitignore` excludes it (e.g. `coverage/` and `*.cobertura.xml`, with `.gitignore` the full source of truth) so a blanket `git add -A` won't stage the untracked output. *Prevents: coverage silently going unreported, a test project stranded on a runner the current SDK refuses, a stale and unused token, a coverage regression blocking an unrelated PR, and a coverage artifact committed by a blanket add.* ### D2 - Input/State Validation at Entry @@ -175,15 +175,15 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input - **D4.1 Gated single-branch publish.** Output: PRs smoke-test and publish nothing. A **human merge never auto-publishes**. A first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it: publish on a **code-affecting bot push to `main`** (gated to the codegen App / Dependabot `github.actor`, with an Actions-only bump matching no release path and publishing nothing), a **dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker). A source-only repo publishes on dispatch only. Each run builds one branch. - **D4.2 Tag the built commit.** Output: the release `target_commitish` is the built commit's SHA (NBGV's `GitCommitId`), never a branch name or a separately re-resolved ref. *Prevents: the tag landing on the default branch instead of the built tree.* -- **D4.3 Release contents.** Output: every release contains a tag on the built commit plus the auto source zip, README, and LICENSE. File targets attach `release-asset-*`. The `prerelease` value equals `branch != default`. A no-file-target caller sets `expect_release_assets: false` to reach the tag-only shape. This applies to Docker-only, PyPI-only, and source-only repos. The setting relaxes `fail_on_unmatched_files` and skips the asset download. The release-create step fails when no assets exist and the setting retains its default `true`. A source-only caller also sets every `enable_*` input false. -- **D4.4 No-op republish.** Input: a re-run whose version is unchanged. Output: nothing is re-pushed, because the release-create step is skipped when the tag exists (refreshed only on `workflow_dispatch`), and the paired asset-delete is skipped with it. Registry pushes are no-ops. The NuGet/PyPI publish steps are **not** statically gated on existence. They run and the **server** dedupes (`dotnet nuget push --skip-duplicate` turns a 409 into success, and PyPI does the same under `skip-existing: true`). **Docker always re-pushes** the image (base-image refresh), independently of the release-create skip, within the same run. *Prevents: duplicate releases and wasted pushes.* -- **D4.5 A build failure blocks every publish target.** Input: a real publish where one enabled build fails. Output: nothing publishes. `github-release` needs every build, so a failed build skips it (no tag, no release), and the terminal registry pusher (Docker) needs every other build and guards its `if` with `!failure() && !cancelled()`, so a failed build skips docker too (no image push) while a disabled or unchanged target (skipped, not failed) still lets docker push. *Prevents: a partial publish, e.g. a Docker image pushed while .NET publish failed and no release was cut.* A package target's separate publish job needs its own gate for the same reason, since it sits outside the `github-release` and Docker `needs:` chains: it `needs:` the release-task call, so a failed build skips it with the rest. +- **D4.3 Release contents.** Output: every release contains a tag on the built commit plus the auto source zip, README, and LICENSE. File targets attach `release-asset-*`. The `prerelease` value equals `branch != default`. A no-file-target caller sets `expect_release_assets: false` to reach the no-asset shape. This applies to Docker-only, PyPI-only, and source-only repos. A NuGet target is not among them, since its leaf uploads a `release-asset-*` carrying the package, so a NuGet-only caller keeps the default `true`. The setting relaxes `fail_on_unmatched_files` and skips the asset download. The release-create step fails when no assets exist and the setting retains its default `true`. A source-only caller also sets every `enable_*` input false. +- **D4.4 No-op republish.** Input: a re-run whose version is unchanged, on a schedule or push trigger. Output: nothing is re-pushed, because the release-create step is skipped when the tag exists, and the paired asset-delete is skipped with it. A **dispatch** re-run refreshes the release instead and runs that delete with it, which is why a dispatch-only publisher records this item's skip leg as unreachable rather than failed. Registry pushes are no-ops. The NuGet/PyPI publish steps are **not** statically gated on existence. They run and the **server** dedupes (`dotnet nuget push --skip-duplicate` turns a 409 into success, and PyPI does the same under `skip-existing: true`). **Docker always re-pushes** the image (base-image refresh), independently of the release-create skip, within the same run. *Prevents: duplicate releases and wasted pushes.* +- **D4.5 A build failure blocks every publish target.** Input: a real publish where one enabled build fails. Output: nothing publishes. `github-release` needs every build and carries the same `!failure() && !cancelled()` guard the terminal registry pusher (Docker) does, since the implicit `success()` would otherwise skip both on every run that disables a target rather than only on a failed one. A failed build therefore skips the release (no tag, no release), and Docker, which needs every other build, skips with it (no image push) while a disabled or unchanged target (skipped, not failed) still lets docker push. *Prevents: a partial publish, e.g. a Docker image pushed while .NET publish failed and no release was cut.* A package target's separate publish job needs its own gate for the same reason, since it sits outside the `github-release` and Docker `needs:` chains: it `needs:` the release-task call, so a failed build skips it with the rest. What no gate covers is a failed **push**, because that job runs after the whole release task and therefore after `github-release`, for the trusted-publishing reason section 3's package-registry bullet gives. A rejected token exchange, a registry outage, or a trusted-publishing policy naming the wrong workflow file therefore leaves a published release and tag for a version that never reached the registry. The recovery is a re-dispatch rather than a cleanup. A dispatch names a branch, `main` or `develop` per D2.3, and never a commit, so what it builds is that branch's tip at dispatch time. A re-dispatch therefore refreshes the failed version's release (D4.4) and runs its push again only while the tip is still the commit whose push failed. Once the tip has moved a re-dispatch builds the new tip, since NBGV derives the version from git height, so it publishes a further version and leaves the first release without its package. - **D4.6 Deploy verification names the release.** Input: a deploy to a filesystem on a host the project owns that completes without error. Output: a check against the running host asserts **which release is answering**, not merely that it answers. The artifact stamps its own version into the configuration it ships, and the check compares that against the version just installed, **waiting for convergence to a bounded timeout** rather than sampling once, because content goes live the instant a pointer moves while server rules wait on an asynchronous reload. The same check asserts **which environment** answered, since several environments serve a byte-identical artifact and a proxy rule aimed at the wrong one answers healthily under the right hostname. An unreachable host is reported distinctly from an HTTP status. *Prevents: a green deploy over a host still serving the previous release's configuration, a URL contract checked against the wrong environment, and a dead config watcher read as a routing fault.* ### D5 - Resource Cleanup - **D5.1 Delete at the point of consumption.** Output: the job that downloads a **cross-job** transfer artifact deletes it (by exact name/pattern) right after consuming it. An intermediate consumed only within the same run MAY instead rely on the `retention-days: 1` backstop. *Prevents: transfer artifacts accumulating against the storage quota.* -- **D5.2 Gate the delete to the consumer's condition.** Output: the delete runs under the **same** condition as its consuming step. Where the consumer is conditional (the GitHub release create), the delete is conditional too. Where the consumer always runs when its job runs (a package publish job's push), the delete always runs, so on a no-op re-run the `release-asset-*` delete is **skipped** while the `nuget-build-*` and `pypi-build-*` deletes still **run** (their publish ran). *Prevents: deleting freshly built assets on a no-op re-run.* +- **D5.2 Gate the delete to the condition that made the artifact redundant.** Output: the delete runs exactly when the consumption it follows happened. Where the consumer is a conditional step (the GitHub release create), the delete carries that same condition. Where the consumer is a step that always attempts once its job runs (a package publish job's push), the delete is gated on the **download** having succeeded rather than on the push, as `if: ${{ !cancelled() && steps..outcome == 'success' }}`. A step whose `if:` carries no status-check function, an absent `if:` included, inherits `success()` instead, which skips it on exactly the failed push where the artifact is already downloaded and the release is already cut. So on a no-op re-run that is not a dispatch the `release-asset-*` delete is **skipped** with the release create it follows, while the `nuget-build-*` and `pypi-build-*` deletes still **run**. A dispatch re-run refreshes the release instead (D4.4), so its asset delete runs with it. Deleting the `nuget-build-*` or `pypi-build-*` artifact on the failed-push path costs the run its **Re-run failed jobs** route, since the re-run's download finds nothing, so the recovery for a failed push is the re-dispatch D4.5 names rather than a re-run. *Prevents: deleting freshly built assets on a no-op re-run, and stranding a downloaded artifact when the push it fed fails.* - **D5.3 Best-effort.** Output: cleanup is `continue-on-error`, tolerates a failed listing, and deletes **all** matching ids. *Prevents: a cleanup hiccup reddening a job whose publish succeeded.* - **D5.4 Retention backstop.** Output: **every** `upload-artifact` sets `retention-days: 1`. - **D5.5 Never blanket-delete.** Output: cleanup MUST NOT enumerate and delete the run's whole artifact set. *Prevents: destroying diagnostic/log artifacts and auto-emitted build-records.* @@ -201,7 +201,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input - **D7.1 Publisher serializes.** Output: the publisher uses a **global, ref-independent** concurrency group with `cancel-in-progress: false`. *Prevents: a schedule and a dispatch double-pushing, or a cancelled publish leaving a partial release.* - **D7.2 A called job's permissions block is validated before its `if:`.** Output: a reusable job declares `permissions:` only where **every** caller grants that scope at startup, and otherwise declares none and runs under whatever the calling job granted. A callee's extra scope (e.g. `actions: write` for cleanup, or `id-token: write` for OIDC) is granted by the caller and appears at exactly the one entry point that needs it. *Prevents: a `startup_failure` on every caller that does not grant a scope only one target needs, including a smoke build under a read-only pull request token.* - **D7.3 Boolean inputs both forms.** Output: declared in both trigger blocks, compared against `true` and `'true'`. -- **D7.4 Optional-dependency chaining.** Output: cross-job conditions allowlist `success`/`skipped` explicitly. +- **D7.4 Optional-dependency chaining.** Output: cross-job conditions allowlist `success`/`skipped` explicitly, paired with a status-check function such as `always()` or `!failure() && !cancelled()`. Without one the implicit `success()` applies and is false the moment any `needs:` job skipped, which is the case the allowlist exists to admit. *Prevents: a condition that reads as tolerant of a skipped dependency and is dead in exactly that case.* ### D8 - Bots / Automation @@ -228,20 +228,20 @@ Read the workflow files plus `version.json` and assert the structural fact behin **Core (every repo):** -- **D1:** a `changes` paths-filter job exists, covers each of the repo's targets, and **excludes** `.github/workflows/**`; the PR entry workflow's smoke call sets every publish flag its release task declares to false (`github`/`dockerhub`, and a package-push flag there is itself a finding, per section 1); a pushing leaf receives `smoke: true` and a derived `push` (false on smoke), and a build-only leaf receives `smoke: true` with no `push` to derive; every build-task `upload-artifact` (and any aggregation job) is gated `!smoke`; the aggregator `needs:` the `changes` and validation jobs, blocks on `failure`/`cancelled`, passes on `skipped`; a validation job runs unconditionally. +- **D1:** a `changes` paths-filter job exists wherever the repo has a smoke build, with one entry per target naming the paths that target is built from, so a change touching no target marks nothing (a filter written as a negation instead marks a docs-only change and fails D1.1); the PR entry workflow's smoke call sets every publish flag its release task declares to false (`github`/`dockerhub`, and a package-push flag there is itself a finding, per section 1); a pushing leaf receives `smoke: true` and a derived `push` (false on smoke), and a build-only leaf receives `smoke: true` with no `push` to derive; every build-task `upload-artifact` (and any aggregation job) is gated on smoke being false, written `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings; the aggregator `needs:` the validation job, and the `changes` and `smoke-build` jobs too wherever the repo has a smoke build, blocks on `failure`/`cancelled`, and passes on a **skipped smoke build**, so a no-build repo's aggregator, having only the validation job to read, requires that job to have succeeded; a validation job runs unconditionally. - **D2:** an entry validation job/step exists per complex-input workflow; the release gate checks both directions, strips `+buildmetadata`, and skips on smoke; the publisher rejects a dispatch from a ref other than `main` or `develop`. - **D3:** each run builds one branch, so NBGV classifies `github.ref` directly (no `IGNORE_GITHUB_REF`), and the default-branch literal in the gate (`== 'main'`), the `prerelease` expression (`!= 'main'`), and `version.json`'s `publicReleaseRefSpec` all name the repo's actual default branch. -- **D4:** `target_commitish` is the NBGV commit id; `prerelease` equals `branch != default`; the release-create step is gated `exists == 'false' || github.event_name == 'workflow_dispatch'` (the step output is the string `'false'`, not a boolean); the asset-delete step is gated identically. A dispatch-only publisher (`releaseTrigger: dispatch-only`) may omit the gate and the exists-check entirely: every run is a dispatch, so the skip leg can never fire and create-or-refresh is unconditional. Record the gate N/A there, not missing. -- **D5:** each cross-job transfer artifact has a delete step at its consumer, gated to the consumer's condition, `continue-on-error: true`, looping all ids; **every** upload sets `retention-days: 1`; **no** `.artifacts[].id` blanket delete exists anywhere. +- **D4:** `target_commitish` is the NBGV commit id; `prerelease` equals `branch != default`; the release-create step is gated `exists == 'false' || github.event_name == 'workflow_dispatch'` (the step output is the string `'false'`, not a boolean); the asset-delete step carries that same condition, narrowed by `inputs.expect_release_assets`. A dispatch-only publisher (`releaseTrigger: dispatch-only`) may omit the gate and the exists-check entirely: every run is a dispatch, so the skip leg can never fire and create-or-refresh is unconditional. Record the gate N/A there, not missing. +- **D5:** each cross-job transfer artifact has a delete step at its consumer, gated so it runs exactly when the consumption happened rather than when the whole job succeeded (D5.2), `continue-on-error: true`, looping all ids; **every** upload sets `retention-days: 1`; **no** `.artifacts[].id` blanket delete exists anywhere. - **D6:** the release download uses `pattern:`/`merge-multiple:` (no `artifact-ids:`). Branch-derived config reads `inputs.branch` (a `github.ref_name` in such config is a finding). Artifact names are branch-suffixed. The target set is consistent across the release task (both the `github-release` and `build-docker` `needs:` lists), the paths-filter, the `smoke-build` enable-forward, and any separate `publish-` job the package-registry seam requires. The `inputs.branch` rule above binds a called leaf, while a `publish-` job is in the publisher and reads `github.ref_name` correctly. -- **D7:** the publisher concurrency group is ref-independent with `cancel-in-progress: false`. A reusable job declares `permissions:` only where every caller grants that scope at startup, per D7.2. Boolean `if:` uses both forms. +- **D7:** the publisher concurrency group is ref-independent with `cancel-in-progress: false`. A reusable job declares `permissions:` only where every caller grants that scope at startup, per D7.2. A boolean input declared in both trigger blocks is compared in both forms (D7.3). - **D8/D9:** merge-bot concurrency keys on PR number. The upstream tracker's branch prefix matches a merge-bot rule (wrapper repos). Actions are SHA-pinned. Names/shells/conditionals follow section 2. **Per-type addenda (apply only the ones present):** - **.NET publish:** the smoke runtime set is a strict non-empty subset of the full runtime set. The selected set runs sequentially inside one composite-action job. A non-smoke run uploads one `release-asset--dotnet-publish` artifact, while a smoke run skips the archive and upload steps. -- **NuGet:** `publish-nuget` is a job in the repo's own publisher, never inside the release task and never in a reusable workflow a different repository hosts. `id-token: write` appears on that job only, absent from the build and PR paths, beside `actions: write` for the artifact cleanup. The push uses `--skip-duplicate` and is gated by that job's `needs:` on the release-task call, never on an existence check, so a PR never reaches it. The job consume-then-deletes `nuget-build-` per D5.1. `*.nupkg` push also carries the paired `.snupkg` to the symbol server where symbols are enabled. The `release-asset` `.7z` carries the package(s). -- **PyPI:** `publish-pypi` declares `environment: { name: pypi }`. `id-token: write` appears only on that job (absent from the build/PR path). `skip-existing: true` is set on the publish action. The build artifact is deleted after publish. The `pypi` environment has a deployment-branch rule. +- **NuGet:** `publish-nuget` is a job in the repo's own publisher, never inside the release task and never in a reusable workflow a different repository hosts. `id-token: write` appears on that job only, absent from the build and PR paths, beside `actions: write` for the artifact cleanup. The push uses `--skip-duplicate` and is gated by that job's `needs:` on the release-task call, never on an existence check, so a PR never reaches it. The job consume-then-deletes `nuget-build-` under the download step's own success, per D5.1 and D5.2. `*.nupkg` push also carries the paired `.snupkg` to the symbol server where symbols are enabled. The `release-asset` `.7z` carries the package(s). +- **PyPI:** a PyPI-only repo's caller passes `expect_release_assets: false`, since the PyPI leaf contributes no release asset. `publish-pypi` declares `environment: { name: pypi }`. `id-token: write` appears only on that job (absent from the build/PR path). `skip-existing: true` is set on the publish action. The build artifact is deleted under the download step's own success, per D5.2. The `pypi` environment has a deployment-branch rule. - **Docker:** a Docker-only repo's caller passes `expect_release_assets: false`. The leaf reads the external state file for the tag instead of `SemVer2` (wrapper repos only, since a plain Docker repo correctly tags off `SemVer2` and records this N/A). The readme job is gated main-only, both by the caller's branch input and inside the hub-hosted `publish-docker-readme-task.yml` itself. The docker-readme task validates `repositories` XOR `manifest`+`manifest-jq`. The buildcache follows D9.4. - **Static site deployed to a host:** the generator is pinned by version **and** by a checksum verified before install, declared once across the workflows that install it. The deploy is a dispatch carrying an environment choice, with concurrency keyed on the **environment** and `cancel-in-progress: false`, and production gated to the default branch while any ref may reach a non-production environment. The reusable callee re-asserts the environment name in a job of its own. The upload targets a per-release directory and carries no delete flag at the environment root, and the pointer flip is a separate step. The terminal check asserts the golden-list length floors first, then the environment, then the release id, then the URL contract. Retention is bounded by a declared count and one side is recorded as owning the prune: the deploy asserts it where the credential can observe the destination, and the host owns it where the credential is confined write-only (D5.6). @@ -253,13 +253,13 @@ For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the | --- | --- | --- | --- | | S1 | PR touching a build target | `changes` flags it; validation runs; that target's smoke build runs; no push, **no uploads**; validate-release **skipped (smoke), succeeds**; release **skipped**; aggregator **success**; version = prerelease; no release; no dangling artifacts | D1, D2.2, D3 | | S2 | PR changing only docs | smoke-build **skipped**, validation runs, aggregator **success** | D1.1, D1.5 | -| S3 | PR changing only `.github/workflows/**` | filter excludes -> smoke-build **skipped**, aggregator **success** | D1.4 | -| S4 | PR base = default branch, carrying a build target | smoke versions as prerelease, validate-release **skipped (smoke)** so the default-branch arm does **not** fire, aggregator **success**, promotion not blocked | D1.3, D2.2 | +| S3 | PR changing only `.github/workflows/**` | the filter marks no target -> smoke-build **skipped**, validation runs, aggregator **success** | D1.4 | +| S4 | PR base = default branch, carrying a build target | smoke versions as prerelease, validate-release **skipped (smoke), succeeds** so the default-branch arm does **not** fire, aggregator **success**, promotion not blocked | D1.3, D2.2 | | S5 | bot push to `main` not touching a release path (e.g. an Actions bump) | the paths filter excludes it, so nothing publishes | D4.1 | | S6 | code-affecting **bot** push to `main` (a human push/promotion, or any develop push, does not) | the `plan` job gates it to the App/Dependabot actor, and `main` publishes a release | D3, D4 | | S7 | publish run (schedule, a bot push to main, or a dispatch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, readme run; `develop` -> `X.Y.Z-g`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; each package build-artifact (`nuget-build-*`, `pypi-build-*`) deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 | | S8 | dispatch from a ref other than `main` or `develop` | **fails fast** | D2.3 | -| S9 | re-run publish, version unchanged | release-create **skipped**, `release-asset-*` delete **skipped**; NuGet/PyPI pushes no-op (server dedupe); **package build-artifacts still deleted** (their publish ran); **Docker still re-pushes** the image; no duplicate release | D4.4, D5.2 | +| S9 | re-run publish on a schedule or push trigger, version unchanged (a dispatch re-run refreshes the release instead, per D4.4) | release-create **skipped**, `release-asset-*` delete **skipped**; NuGet/PyPI pushes no-op (server dedupe); **package build-artifacts still deleted** (their download succeeded); **Docker still re-pushes** the image; no duplicate release | D4.4, D5.2 | | S10 | branch/version classification disagree | validate-release **fails loud**, build/publish skip | D2.2 | | S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a `-` PR -> merge-bot auto-merges -> the `main` pin publishes via the gate (a develop pin does not auto-publish, shipping instead via a develop dispatch or promotion) | D8.3, D3.5 | | S12 | deploy dispatch naming an environment | the ref gate runs **first** (production from the default branch only, any ref to a non-production environment); validation runs; the callee re-asserts the environment name; a release installs under its own id; the pointer flips as a separate step; retention is bounded by whichever of the two D5.6 shapes the repo uses, so a deploy whose credential can observe the destination asserts the count converged and one confined write-only leaves it to the host; the live check asserts the environment and the release id, waiting out the reload, then the URL contract; **no tag and no release are created** | D2.1, D4.6, D5.6, D7.1 | @@ -287,13 +287,13 @@ The workflow is **operational** iff every *applicable* 5A item passes and every Each type maps the *applicable* S-scenarios onto its targets. The differences are which leaf tasks exist and what each produces, which 5A addenda apply, and which scenarios are N/A. Walking these is the self-check that the contract holds for each shape. - **.NET publish.** The target runs a sequential `dotnet publish` runtime loop inside one composite-action job. Configuration is Release on the default branch and Debug otherwise. A non-smoke run builds the full runtime set, archives the combined output as a `.7z`, and uploads it as `release-asset--dotnet-publish`. The archive is named from the project file stem unless `dotnet_publish_asset_name` overrides it. A smoke run builds a two-runtime subset and skips the archive and upload steps, so it uploads nothing. S1 smoke-builds that subset after a .NET project change. S7 attaches the 7z from a non-smoke run. The non-default leg sets `prerelease=true`, and the default leg sets `prerelease=false`. GitHub marks the stable default release "Latest" automatically. -- **NuGet.** The leaf uploads both `release-asset--nuget` and `nuget-build-` on a non-smoke run and pushes nothing, and a separate `publish-nuget` job in the repo's own publisher consumes the second and runs `dotnet nuget push *.nupkg --skip-duplicate`, then deletes it. Section 3's package-registry seam says why the push sits there rather than in the leaf. Configuration is Release on the default branch, Debug otherwise. Where symbols are enabled (`snupkg`), the push auto-carries the paired `.snupkg` to NuGet.org's symbol server and the release-asset `.7z` also contains it, a triple surface. NuGet.org derives `isPrerelease` from the SemVer2 `-g` suffix (the workflow sets no such flag). Test: S7 non-default leg publishes a prerelease package + asset, default a stable; S9 re-run is a server-side `--skip-duplicate` no-op. 5C: query NuGet.org for both versions and the symbol package. -- **PyPI.** The leaf builds and uploads `pypi-build-`. A **separate** `publish-pypi` job (with `environment: pypi`, `id-token: write`, `actions: write`) does the OIDC Trusted-Publishing upload with `skip-existing: true`, then **consume-then-deletes** the build artifact, **unconditionally on consume**, so on S9 it is deleted even though the `release-asset-*` delete is skipped. The version is `AssemblyFileVersion` with `.dev0` appended on `develop` only, and must stay `--pre`-selectable and sorted above the default release. PyPI contributes no `release-asset-*`. A PyPI-only repo sets `expect_release_assets: false` at the caller. Test: S7 default leg publishes a release, non-default a `.dev0`; S9 is a `skip-existing` no-op; 5C inspects the `dist/*` filenames and the compute-version log. +- **NuGet.** The leaf uploads both `release-asset--nuget` and `nuget-build-` on a non-smoke run and pushes nothing, and a separate `publish-nuget` job in the repo's own publisher consumes the second and runs `dotnet nuget push *.nupkg --skip-duplicate`, then deletes it under the download step's own success (D5.2). Section 3's package-registry seam says why the push sits there rather than in the leaf. Configuration is Release on the default branch, Debug otherwise. Where symbols are enabled (`snupkg`), the push auto-carries the paired `.snupkg` to NuGet.org's symbol server and the release-asset `.7z` also contains it, a triple surface. NuGet.org derives `isPrerelease` from the SemVer2 `-g` suffix (the workflow sets no such flag). Test: S7 non-default leg publishes a prerelease package + asset, default a stable; S9 re-run is a server-side `--skip-duplicate` no-op. 5C: query NuGet.org for both versions and the symbol package. +- **PyPI.** The leaf builds and uploads `pypi-build-`. A **separate** `publish-pypi` job (with `environment: pypi`, `id-token: write`, `actions: write`) does the OIDC Trusted-Publishing upload with `skip-existing: true`, then **consume-then-deletes** the build artifact under the download step's own success (D5.2), so on S9 it is deleted even though the `release-asset-*` delete is skipped. The version is `AssemblyFileVersion` with `.dev0` appended on `develop` only, and must stay `--pre`-selectable and sorted above the default release. PyPI contributes no `release-asset-*`. A PyPI-only repo sets `expect_release_assets: false` at the caller. Test: S7 default leg publishes a release, non-default a `.dev0`; S9 is a `skip-existing` no-op; 5C inspects the `dist/*` filenames and the compute-version log. - **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 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]. +- **Data / asset library.** A single new leaf: validate -> zip -> upload `release-asset--library` (`retention-days: 1` per D5.4, upload gated on smoke being false 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 caller's own validation job is replaced by a type-appropriate validator only where the reusable one cannot express this repo's validation, with the aggregator re-pointed to the replacement (D1.2). `smoke-build` keeps `needs: [changes]`, as D1.2 and the hub's release-with-smoke 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 caller's own job reaching the reusable validator. The aggregator `needs:` that validation job (D1.2), and a retained `smoke-build` job `needs:` the `changes` job rather than the validation job. 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, and S10. S7 covers the release, S8 the dispatch guard, and S10 the classification gate. S9 is recorded N/A, since S9's input is a schedule or push re-run and this publisher triggers on neither, so its no-op skip leg can never fire. S2-S6, D5/D6 artifact items, and all per-type 5A addenda are N/A. The artifact-lifecycle and registry clauses of S7 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 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/S10, and S12/S13 (the deploy dispatch). N/A: S2-S4, S9, 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, and S10 scenarios also apply, with S9 recorded N/A for the same dispatch-only reason. 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/docs/reusable-workflows.md b/docs/reusable-workflows.md index 70f97b20..7b50061b 100644 --- a/docs/reusable-workflows.md +++ b/docs/reusable-workflows.md @@ -195,7 +195,7 @@ Hub: `build-release-task.yml` provides the `dotnet-publish`, `build-nuget`, and - [x] The [pilot smoke run][pilot-smoke-run] exercised `build-release-task.yml` on PhotoCleaner's pull request. Hub defaults ran get-version, validate-release, `dotnet-publish`, `docker-prepare`, and `build-docker`. NuGet, PyPI, and the base build skipped. - [x] Cross-repository `$/` resolution observed on PhotoCleaner pull request #58 in [self-reference smoke run][self-reference-smoke-run]: nested get-version and Docker tasks, the .NET publish default, and the Docker prepare default all resolved from the pinned hub feature commit and passed. - [x] A real publish through `build-release-task.yml` observed on the PhotoCleaner pilot, [pilot publish run][pilot-publish-run]: release `1.1.11` on `fa91db0` with `Publish GitHub release job` and `Build Docker image job` both succeeding. -- [x] The first real (non-smoke) NuGet publish through the chain, from ptr727/Utilities at `f3b4cc9` (`2.0.526`), failed the NuGet.org token exchange `HTTP 401` because the OIDC `job_workflow_ref` claim named this hub's `build-release-task.yml` rather than the publishing repository's own workflow. The build itself passed and every later job skipped, so the run published nothing and left no partial release. PhotoCleaner, the pilot, sets `enable_nuget: false`, so the NuGet leg had never run for real. Fixed by moving the push to the caller stub's own `publish-nuget` job, the shape `build-pypi` already used, per [Adopting the Release Chain][adopting-the-release-chain]. A smoke build never reaches either push, so no pull request can catch this class and each adopter's first real release is where it surfaces. Each NuGet adopter owes a stub edit with its next pin bump, dropping `nuget: true`, the `NUGET_USERNAME` secret mapping and `id-token: write` from its `publish` job and adding the `publish-nuget` job, since those two names are no longer declared on the task and a pin bump without the edit startup-fails. +- [x] The first real (non-smoke) NuGet publish through the chain, from ptr727/Utilities at `f3b4cc9` (`2.0.526`), failed the NuGet.org token exchange `HTTP 401` because the OIDC `job_workflow_ref` claim named this hub's `build-release-task.yml` rather than the publishing repository's own workflow. The build itself passed and every later job skipped, so the run published nothing and left no partial release. PhotoCleaner, the pilot, sets `enable_nuget: false`, so the NuGet leg had never run for real. Fixed by moving the push to the caller stub's own `publish-nuget` job, the shape `build-pypi` already used, per [Adopting the Release Chain][adopting-the-release-chain]. A smoke build never reaches either push, so no pull request can catch this class and each adopter's first real release is where it surfaces. Each NuGet adopter owes a stub edit with its next pin bump, dropping `nuget: true`, the `NUGET_USERNAME` secret mapping and `id-token: write` from its `publish` job and adding the `publish-nuget` job, since those two names are no longer declared on the task and a pin bump without the edit startup-fails. Every NuGet and PyPI adopter also owes its publish job the artifact-delete condition `if: ${{ !cancelled() && steps.download.outcome == 'success' }}` with `id: download` on the download step, per D5.2, since a step left without one skips on exactly the failed push it exists for. The same adopter also confirms its nuget.org trusted-publishing policy names its own `publish-release.yml` and repoints it where the pre-fix workaround for this same failure, pointing the policy at the hub task, left it naming `build-release-task.yml`, since the `job_workflow_ref` claim moves back to the publishing repository with the push. ptr727/Utilities proved both halves on one commit, a first release run failing the token exchange on the stale policy and the next succeeding after the policy was repointed. - [ ] Proof: the next PhotoCleaner release names its .NET publish asset `PhotoCleaner.7z`, which the asset-name fix in this repository derives from the project file. Tick with the release. - [ ] `reports/workflow-reuse.md` regenerated with `build-release-task.yml` and `build-docker-task.yml` at 0 copies (hub-only files) and `publish-release.yml` showing callers equal to copies. @@ -271,7 +271,7 @@ A downstream repo replaces its own `validate-task.yml` job bodies and its `test- **No-build repos** carry the operational trigger shape [WORKFLOW.md "Branch Model"][workflow] states and [#585][issue-585] settles: a direct push to `develop` runs CI advisory (no required check binds the direct-commit allowance), and a `pull_request` to `main` or `develop` runs it pre-merge and actionable. A release-model repo with no build target takes the same stub with a `pull_request: branches: [main, develop]` trigger instead, since it has no direct-commit allowance to keep advisory. -**Release repos with a smoke build** carry the standard `pull_request` trigger, a `changes` paths-filter job (WORKFLOW.md D1.1: each of the repo's own targets gets a filter entry, and `.github/workflows/**` is excluded per D1.4), and a `smoke-build` job. The smoke build calls the repo's own `./.github/workflows/build-release-task.yml` by local path rather than a hub task, since that orchestrator is not hosted until [Stage 4][stage-4]. +**Release repos with a smoke build** carry the standard `pull_request` trigger, a `changes` paths-filter job (WORKFLOW.md D1.1: each of the repo's own targets gets an entry listing that target's own paths, which leaves both docs and `.github/workflows/**` matching nothing and satisfies D1.4 without naming a workflow path at all), and a `smoke-build` job. The smoke build calls the repo's own `./.github/workflows/build-release-task.yml` by local path rather than a hub task, since the renamed .NET publish interface the hub-hosted shape needs is not in a hub release yet, per [Adopting the Release Chain][adopting-the-release-chain]. ```yaml name: Test pull request action @@ -289,7 +289,9 @@ permissions: {} jobs: - # Add one filter entry per target this repo builds; a touched target must never fall through unfiltered (D1.1). + # One entry per target this repo builds, listing that target's own paths, so a touched target never falls through unfiltered (D1.1). + # A negation such as '!.github/workflows/**' marks a docs-only pull request as a target change, which scenario S2 requires to skip. + # A repo carrying its own build hook lists .github/actions/** too, since that hook builds the target and actionlint does not reach it. changes: name: Detect changed targets job runs-on: ubuntu-latest @@ -307,8 +309,14 @@ jobs: with: filters: | release: - - '!.github/workflows/**' - + - 'Widget/**' + - 'Directory.Build.props' + - 'Directory.Packages.props' + - 'global.json' + - 'version.json' + - '.github/actions/**' + + # A C# or Python repo adds secrets: with CODECOV_TOKEN mapped by name here, or its pull request coverage reaches Codecov with no token (D1.6). validate: name: Validate sources job uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@ # @@ -410,7 +418,7 @@ A downstream repo replaces its carried release orchestrator and per-target leaf Neither package push runs inside the hub task, and that is a constraint rather than a preference. NuGet.org and PyPI trusted publishing both validate the OIDC token's `job_workflow_ref` claim against the repository that owns the package, and that claim names the workflow file the job actually ran from. A job running from a hub task therefore carries `ptr727/ProjectTemplate/.github/workflows/build-release-task.yml@`, and the token exchange is rejected, NuGet.org answering `HTTP 401` with `does not start with //.github/workflows/` and PyPI rejecting the same shape under its own code. A caller hook does not avoid it, since a composite action runs inside the hub's job and leaves the claim unchanged. The hub task instead builds the package and uploads it as `nuget-build-` or `pypi-build-`, and the caller stub's own `publish-nuget` or `publish-pypi` job downloads that artifact and pushes, so the claim names the publishing repository. A smoke build never reaches either push, which is why a pull request cannot catch this and the first real release is where it surfaces. -The trusted-publishing policy on NuGet.org and PyPI therefore keeps naming the publishing repository and its own `publish-release.yml`, and adopting the chain does not change it. Pointing a policy at the hub's workflow file instead would let any repository calling that task publish that package, so it is not the fix. +The trusted-publishing policy on NuGet.org and PyPI therefore names the publishing repository and its own `publish-release.yml`. Pointing a policy at the hub's workflow file instead would let any repository calling that task publish that package, so it is not the fix. Confirm the policy before the first release after adopting. A repository whose policy already names its own `publish-release.yml` needs no edit. A repository whose policy names `build-release-task.yml`, which is how the `HTTP 401` was worked around before the push moved, mismatches in the other direction, and its first release after adopting fails the token exchange with `expected 'build-release-task.yml', actual 'publish-release.yml'` until the policy is repointed back. Neither direction is catchable before that release, since a smoke build never reaches the token exchange. The stub keeps its own trigger policy exactly as today: `workflow_dispatch` plus a main-only weekly `schedule` for a Docker repo, or `workflow_dispatch` plus a paths-filtered `push` to `main` for a NuGet or PyPI repo whose merges should auto-publish. What moves to the hub is the release-gate decision, the build/version/publish job graph, and the Docker core, never the trigger. This is the full shape, a NuGet-library repo whose merges publish: @@ -425,6 +433,7 @@ on: - 'version.json' - 'Directory.Build.props' - 'Directory.Packages.props' + - 'global.json' workflow_dispatch: concurrency: @@ -488,6 +497,7 @@ jobs: actions: write steps: - name: Download build artifacts step + id: download uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: nuget-build-${{ github.ref_name }} @@ -512,7 +522,9 @@ jobs: --source https://api.nuget.org/v3/index.json \ --api-key "$NUGET_API_KEY" \ --skip-duplicate + # Gated on the download rather than on the push, per D5.2: a step with no if: inherits success() and would skip on exactly the failed push where the artifact is already downloaded and the release is already cut. - name: Delete consumed NuGet build artifact step + if: ${{ !cancelled() && steps.download.outcome == 'success' }} continue-on-error: true env: GH_TOKEN: ${{ github.token }} @@ -546,6 +558,7 @@ A Docker repo's stub adds `schedule: - cron: '0 2 * * MON'` to the trigger block actions: write steps: - name: Download build artifacts step + id: download uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: pypi-build-${{ github.ref_name }} @@ -555,7 +568,9 @@ A Docker repo's stub adds `schedule: - cron: '0 2 * * MON'` to the trigger block with: packages-dir: ./dist skip-existing: true + # Gated on the download rather than on the publish, per D5.2, for the same reason the NuGet stub above gives. - name: Delete consumed PyPI build artifact step + if: ${{ !cancelled() && steps.download.outcome == 'success' }} continue-on-error: true env: GH_TOKEN: ${{ github.token }} @@ -668,7 +683,6 @@ Four things the hub cannot prove fall to the first downstream adopter. They are [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/repo-config/README.md b/repo-config/README.md index db244e89..c8b01a7d 100644 --- a/repo-config/README.md +++ b/repo-config/README.md @@ -18,7 +18,7 @@ The result is **exactly two rulesets named `develop` and `main`**, and the names ## Secrets -Publish credentials required per mechanism are enumerated in `spec/secrets.json`. A repo needs only the mechanisms its own publish targets use, so a source-only repo needs none of the publish credentials below. NuGet and PyPI use keyless OIDC Trusted Publishing (no stored key, so the publish job needs `id-token: write`, and PyPI additionally an `environment: pypi` gate). That publish job belongs to the repo's own workflow file, since trusted publishing validates the OIDC token's `job_workflow_ref` claim against the repository owning the package and rejects a reusable workflow's ref, so `id-token: write` is granted at that one entry point and nowhere else. Docker Hub has no OIDC equivalent and uses a stored `DOCKER_HUB_USERNAME` + `DOCKER_HUB_ACCESS_TOKEN` in both the Actions and Dependabot secret stores. Codegen and merge-bot repos add a GitHub App (`CODEGEN_APP_CLIENT_ID` + `CODEGEN_APP_PRIVATE_KEY` in both stores, and the app must be installed, not just created). App-token call sites use `client-id`, never the deprecated `app-id`. +Publish credentials required per mechanism are enumerated in `spec/secrets.json`. A repo needs only the mechanisms its own publish targets use, so a source-only repo needs none of the publish credentials below. NuGet and PyPI use keyless OIDC Trusted Publishing (no stored key, so the publish job needs `id-token: write`, and PyPI additionally an `environment: pypi` gate). That publish job belongs to the repo's own workflow file, since trusted publishing validates the OIDC token's `job_workflow_ref` claim against the repository owning the package and rejects a reusable workflow's ref, so `id-token: write` is granted at that one entry point and nowhere else. The registry-side policy is the other half of that pairing and is configured on nuget.org or PyPI rather than here: it names the repository and the workflow file the push runs from, so moving the push between workflow files means repointing the policy in the same change. Docker Hub has no OIDC equivalent and uses a stored `DOCKER_HUB_USERNAME` + `DOCKER_HUB_ACCESS_TOKEN` in both the Actions and Dependabot secret stores. Codegen and merge-bot repos add a GitHub App (`CODEGEN_APP_CLIENT_ID` + `CODEGEN_APP_PRIVATE_KEY` in both stores, and the app must be installed, not just created). App-token call sites use `client-id`, never the deprecated `app-id`. ## Repo Settings diff --git a/reports/canonical-review.json b/reports/canonical-review.json index 204cabd2..5c5fc848 100644 --- a/reports/canonical-review.json +++ b/reports/canonical-review.json @@ -129,6 +129,22 @@ "hubCommit": "37d116a2fa0cecf85a220c9005375091db240a8d", "stamp": "2026-09-01T14:33:08Z" }, + { + "unit": ".agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md > No-op republish guarantee", + "digest": "sha256:cf8b084072d372f20beb8e11ea92e958abfeb3a90de8960196760a56db5a98a8", + "reviewer": "agent-skill", + "findings": 9, + "hubCommit": "50d97cd013e013af1fee010dfe8828200792f560", + "stamp": "2026-09-02T16:21:12Z" + }, + { + "unit": ".agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md > Orchestration vs. build: the override seam", + "digest": "sha256:a7de5ead05a2d1746a0e9d737fc1e12af881bbf23a2972401f0eff969f4a7d4c", + "reviewer": "agent-skill", + "findings": 9, + "hubCommit": "50d97cd013e013af1fee010dfe8828200792f560", + "stamp": "2026-09-02T16:21:12Z" + }, { "unit": ".agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md > Per-target subsetting", "digest": "sha256:e881473b1b377dcb2b33900941d6518e8ac95c4e3a4ce9f9c72fa17c3360feb7", @@ -217,6 +233,14 @@ "hubCommit": "a76dda7d67033f43c260d21e3ae6a291e1869c0a", "stamp": "2026-09-01T06:46:39Z" }, + { + "unit": ".agents/skills/workflow-ci-contract/SKILL.md > (preamble)", + "digest": "sha256:b7bf2ed878797d025cd67ba7191b40c1988a1cf0233e0141e73dfe347f1a0836", + "reviewer": "agent-skill", + "findings": 4, + "hubCommit": "99f8de2209405d2dfa8b6d648dc42d55ee3de060", + "stamp": "2026-09-02T19:10:19Z" + }, { "unit": ".agents/skills/workflow-ci-contract/SKILL.md > After Any Workflow Edit", "digest": "sha256:7dc31b8f1e7e25effd4b85078f9099b89cf3663452ed0400cf255df019fbef0e", @@ -243,27 +267,43 @@ }, { "unit": ".agents/skills/workflow-ci-contract/SKILL.md > The Core Behavioral Spine", - "digest": "sha256:f7925cc6a6047d82f54aba505e9e7901699596add310fde6e32a2a5d4825b9b1", + "digest": "sha256:0b53bafe901f2482156fffb07b24cb26968f5cae3c6da1e71bc713c24ab5cfd5", "reviewer": "agent-skill", - "findings": 0, - "hubCommit": "37d116a2fa0cecf85a220c9005375091db240a8d", - "stamp": "2026-09-01T14:33:09Z" + "findings": 5, + "hubCommit": "1de415fbc5de255f04b2b7f71d42f1f240355639", + "stamp": "2026-09-02T19:38:44Z" + }, + { + "unit": ".agents/skills/workflow-ci-contract/references/d-guarantees.md > (preamble)", + "digest": "sha256:f5ff09431c97a4c1c86d29b387a686805c467e7761d54e37ca61e75d4d0b2173", + "reviewer": "agent-skill", + "findings": 5, + "hubCommit": "99f8de2209405d2dfa8b6d648dc42d55ee3de060", + "stamp": "2026-09-02T18:38:20Z" + }, + { + "unit": ".agents/skills/workflow-ci-contract/references/d-guarantees.md > D1: PR Fast-Feedback (Smoke)", + "digest": "sha256:58e3860e81d92ae6d4ec4a84d04c6770dccad4caa8fdb3c12132f5aa29ffaaa8", + "reviewer": "agent-skill", + "findings": 62, + "hubCommit": "9b33edb7e0f55fe2a05630a49d5be99ba1e3eb26", + "stamp": "2026-09-02T15:16:10Z" }, { "unit": ".agents/skills/workflow-ci-contract/references/d-guarantees.md > D4: Release and Publish", - "digest": "sha256:88547d4229a09aa37d7c4c62f4b6a8566729bb3f55378dade9b514a65a431463", + "digest": "sha256:e3fbb2b2de51a2679272e1ff1637681886241da62113ea9020550a3823ef9b43", "reviewer": "agent-skill", - "findings": 0, - "hubCommit": "37d116a2fa0cecf85a220c9005375091db240a8d", - "stamp": "2026-09-01T14:33:09Z" + "findings": 2, + "hubCommit": "c6e7526cfade19f28f0ab09c4ab283ed00295d7e", + "stamp": "2026-09-02T20:49:13Z" }, { "unit": ".agents/skills/workflow-ci-contract/references/d-guarantees.md > D5: Resource Cleanup", - "digest": "sha256:9920002dc94291839cb4bb1b03c02a42daf57ca32e477db178a7b7dbc8955ad9", + "digest": "sha256:37ab54b40d243ff4d1f3864dedcf6e0342a0091003ea28d1283f46f16857bd0e", "reviewer": "agent-skill", - "findings": 0, - "hubCommit": "37d116a2fa0cecf85a220c9005375091db240a8d", - "stamp": "2026-09-01T14:23:11Z" + "findings": 62, + "hubCommit": "9b33edb7e0f55fe2a05630a49d5be99ba1e3eb26", + "stamp": "2026-09-02T15:16:10Z" }, { "unit": ".agents/skills/workflow-ci-contract/references/d-guarantees.md > D6: Seam Conformance", @@ -275,11 +315,27 @@ }, { "unit": ".agents/skills/workflow-ci-contract/references/d-guarantees.md > D7: Concurrency, Permissions, Safety", - "digest": "sha256:95fc50a9adb3ad36c09c9a359b19beac7f65f525347153a1b6c479e745b6f2a2", + "digest": "sha256:c033b50b73c24d96575a7abe6b481323d7b4107eae174340a27430db49af936f", "reviewer": "agent-skill", - "findings": 0, - "hubCommit": "37d116a2fa0cecf85a220c9005375091db240a8d", - "stamp": "2026-09-01T14:23:11Z" + "findings": 14, + "hubCommit": "30c465ae79de328d2745c31df1b05f0d960c42b7", + "stamp": "2026-09-02T15:40:54Z" + }, + { + "unit": ".agents/skills/workflow-ci-contract/references/test-methodology.md > 5A: Static Audit", + "digest": "sha256:a23b5e76946c8b7e03a3afff7b874c42f3204c4ded292a6c7d7ff81db6bf20fe", + "reviewer": "agent-skill", + "findings": 62, + "hubCommit": "9b33edb7e0f55fe2a05630a49d5be99ba1e3eb26", + "stamp": "2026-09-02T15:16:10Z" + }, + { + "unit": ".agents/skills/workflow-ci-contract/references/test-methodology.md > 5B: Trace Scenarios", + "digest": "sha256:b625607723d4f645b89ea84c24ddca4984a9abca614185e8acbe8bc9fd35e460", + "reviewer": "agent-skill", + "findings": 9, + "hubCommit": "50d97cd013e013af1fee010dfe8828200792f560", + "stamp": "2026-09-02T16:21:12Z" }, { "unit": ".github/copilot-instructions.md > Reviewing Carried Fleet Content", @@ -355,11 +411,19 @@ }, { "unit": "GOVERNANCE.md > Workflow YAML Conventions", - "digest": "sha256:78dee33ccdcfa1b7cf25ba9948869f2d8c41ee8b02f3fef17024de6711ca5757", + "digest": "sha256:cbeb861edab7ed851e66c772f148c6102b5c1fe9ae8724d2fbd681a76fab673b", "reviewer": "agent-skill", - "findings": 0, - "hubCommit": "37d116a2fa0cecf85a220c9005375091db240a8d", - "stamp": "2026-09-01T14:33:09Z" + "findings": 14, + "hubCommit": "30c465ae79de328d2745c31df1b05f0d960c42b7", + "stamp": "2026-09-02T15:40:54Z" + }, + { + "unit": "WORKFLOW.md > (preamble)", + "digest": "sha256:ea8cdbdf569d5476aa300eee0f797f0318b1a488ea690955f1cc2244b620d3b3", + "reviewer": "agent-skill", + "findings": 8, + "hubCommit": "99f8de2209405d2dfa8b6d648dc42d55ee3de060", + "stamp": "2026-09-02T19:16:34Z" }, { "unit": "WORKFLOW.md > 1. Purpose and How to Use This Document", @@ -369,37 +433,45 @@ "hubCommit": "c3b2898feef97459d01ce7c0b63de25b6c5524bf", "stamp": "2026-09-01T13:57:20Z" }, + { + "unit": "WORKFLOW.md > 2. Workflow Style Conventions", + "digest": "sha256:047d124867942f07a0e1ce0128de5664ac12b06046336dccf6d59139cff68297", + "reviewer": "agent-skill", + "findings": 11, + "hubCommit": "3cd97cf0ef28d000c83ddd54fa7925fe0c4623f9", + "stamp": "2026-09-02T15:56:03Z" + }, { "unit": "WORKFLOW.md > 3. Architecture", - "digest": "sha256:2a33c96912e83352029b1e131d2c2066dc3f764e37ac2511b0a0e9487f5583b6", + "digest": "sha256:cbdfc5161d7951fc9f968257f9090aa8086f10f5fcb869d3a632d12ccfdb2dbe", "reviewer": "agent-skill", - "findings": 0, - "hubCommit": "78898becaa2b1a62cb4c806d86273210c6390ac5", - "stamp": "2026-09-01T14:48:23Z" + "findings": 62, + "hubCommit": "9b33edb7e0f55fe2a05630a49d5be99ba1e3eb26", + "stamp": "2026-09-02T15:16:10Z" }, { "unit": "WORKFLOW.md > 4. Behavioral Contract: Expected Outcomes", - "digest": "sha256:c3ffe518edcfb301c3743d693a14fef3b566d059ff18b97747003bcb55cceffc", + "digest": "sha256:587d95361c4bd424a65327d3394d266342f13fe05f598b8521bdb7880d781f57", "reviewer": "agent-skill", - "findings": 3, - "hubCommit": "d0541523890324cbbdbb63631c8dd83e6003703a", - "stamp": "2026-09-01T19:25:11Z" + "findings": 5, + "hubCommit": "7ab81feb4467dd56c0406c1fa98d4f2c94d2ef13", + "stamp": "2026-09-02T21:07:56Z" }, { "unit": "WORKFLOW.md > 5. Test Methodology", - "digest": "sha256:b12b797e7ea91e568f2529ce5aa94603a65356e6ccceed0306d2631b44b11c65", + "digest": "sha256:3014749f3cb30f5a0541cbc9cd4dffa76d0bec7b5c4444373b041ad7c54a2094", "reviewer": "agent-skill", - "findings": 0, - "hubCommit": "bde1713355e318c13c2b546ccd871822301bfb3c", - "stamp": "2026-09-01T15:39:23Z" + "findings": 9, + "hubCommit": "445bd440ca7df6fc32a28dd34be9469d2c603f18", + "stamp": "2026-09-02T16:49:49Z" }, { "unit": "WORKFLOW.md > 6. Per-Project-Type Test Walkthroughs", - "digest": "sha256:90cd1e08e32954143484bbd75d2e90fd6b59e70141eac0bcd4e20a5d662335ac", + "digest": "sha256:dcf042d329e97ac6f76e08664a00776c3d68f20a4980b513eee76925d7a6d1d8", "reviewer": "agent-skill", - "findings": 1, - "hubCommit": "d4cb996ed67bed9e327c5a3b3c4b441466bc37bb", - "stamp": "2026-09-02T02:28:24Z" + "findings": 9, + "hubCommit": "398abeedb76d01d87437993255c49ade98d72c99", + "stamp": "2026-09-02T16:58:05Z" } ] } diff --git a/reports/canonical-review.md b/reports/canonical-review.md index f8fe568a..153fa9eb 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: 50 +- covered: 59 - stale: 0 -- never read here: 238 +- never read here: 229 ## Burn-down @@ -200,8 +200,6 @@ A unit is what a reviewer reads whole, decided by the carry manifest rather than ### .agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md - **(preamble)** - never -- **No-op republish guarantee** - never -- **Orchestration vs. build: the override seam** - never - **Reusable-task parameter contract** - never - **Wrapper repos that track an upstream release** - never @@ -298,13 +296,10 @@ A unit is what a reviewer reads whole, decided by the carry manifest rather than ### .agents/skills/workflow-ci-contract/SKILL.md -- **(preamble)** - never - **Why This Exists** - never ### .agents/skills/workflow-ci-contract/references/d-guarantees.md -- **(preamble)** - never -- **D1: PR Fast-Feedback (Smoke)** - never - **D2: Validation at Entry** - never - **D3: Versioning and Classification** - never - **D8: Bots and Automation** - never @@ -313,8 +308,6 @@ A unit is what a reviewer reads whole, decided by the carry manifest rather than ### .agents/skills/workflow-ci-contract/references/test-methodology.md - **(preamble)** - never -- **5A: Static Audit** - never -- **5B: Trace Scenarios** - never - **5C: Live Probe** - never - **Verdict** - never @@ -388,11 +381,6 @@ A unit is what a reviewer reads whole, decided by the carry manifest rather than - **Representative Data in Agent-Authored Text** - never - **Supported Development Platforms** - never -### WORKFLOW.md - -- **(preamble)** - never -- **2. Workflow Style Conventions** - never - ### cspell.json - **(whole file)** - never