diff --git a/.agents/skills/dotnet-codestyle/SKILL.md b/.agents/skills/dotnet-codestyle/SKILL.md index a6a32c03..7eb20897 100644 --- a/.agents/skills/dotnet-codestyle/SKILL.md +++ b/.agents/skills/dotnet-codestyle/SKILL.md @@ -210,7 +210,7 @@ The .NET mechanics, narrowest first: xUnit v3 (`xunit.v3`, not the legacy `xunit`) + AwesomeAssertions (`.Should()` API, never native asserts). Arrange-Act-Assert pattern, descriptive underscore names, `[Theory]`/`[InlineData]` for -parameterized tests. See `references/testing.md` for the framework setup template. +parameterized tests. A test project on `xunit.v3` 4.0.0 or later is MTP-based, and also carries a `global.json` runner declaration, a `Microsoft.Testing.Extensions.CodeCoverage` floor, and no `xunit.runner.visualstudio`. See `references/testing.md` for the framework setup template and that configuration. ## Project configuration diff --git a/.agents/skills/dotnet-codestyle/references/testing.md b/.agents/skills/dotnet-codestyle/references/testing.md index 5a84a178..4ec0c4e6 100644 --- a/.agents/skills/dotnet-codestyle/references/testing.md +++ b/.agents/skills/dotnet-codestyle/references/testing.md @@ -23,3 +23,19 @@ 2. **Organization**: Arrange-Act-Assert pattern. 3. **Naming**: descriptive names with underscores. 4. **Theory tests**: use `[Theory]` with `[InlineData]`. + +## Microsoft.Testing.Platform and coverage + +A test project on `xunit.v3` 4.0.0 or later is MTP-based, and the .NET 10 SDK and later refuse to run one through the VSTest target, so such a project also carries: + +- a root **`global.json`** declaring `{"test": {"runner": "Microsoft.Testing.Platform"}}`, which is what selects the driver `dotnet test` runs the project through, +- **`Microsoft.Testing.Extensions.CodeCoverage`** at **18.9.0 or later**, in place of `coverlet.collector`, whose VSTest data collector MTP ignores without failing, +- no **`xunit.runner.visualstudio`**, the VSTest adapter MTP replaces. + +A project not yet MTP-based keeps the VSTest collector, and that lagging state is a migration owed rather than drift, until its own `xunit.v3` bump 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. + +The CI invocation `WORKFLOW.md` D1.6 requires is `dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`. Two further details of it 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 solution 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, so the report is renamed before the upload reads the directory, per `WORKFLOW.md` D1.6. + +**Diagnosing a local run.** `dotnet test` under the CI configuration reports zero tests on some machines where CI reports the full suite on the same SDK, which reads as a broken repository and is a broken driver. The target string the run prints separates the two: `net10.0` with no architecture means the driver resolved none, and `net10.0|` with no tests means the tests did not register, which is the case that points back at the three requirements above. diff --git a/.agents/skills/operational-vs-release-workflow/SKILL.md b/.agents/skills/operational-vs-release-workflow/SKILL.md index 00915587..5639d9f8 100644 --- a/.agents/skills/operational-vs-release-workflow/SKILL.md +++ b/.agents/skills/operational-vs-release-workflow/SKILL.md @@ -106,10 +106,15 @@ rather than guessing from the repo's contents. `HISTORY.md`, and release notes name the version as `Version 1.0` (the floor), never the concrete build height, which is both wrong (the real height differs) and a maintenance trap. "Correcting" `1.0` to `1.0.0` is a defect. -- **A no-op publish (unchanged NBGV `SemVer2`) re-pushes nothing to any target keyed on the - version string, except Docker, which always re-pushes** to pick up upstream base-image +- **A no-op publish on a schedule or push trigger (unchanged NBGV `SemVer2`) re-pushes nothing to + any target keyed on the version string, except Docker, which always re-pushes** (a dispatch + refreshes the release instead of skipping) to pick up upstream base-image refreshes. Full guarantee and the `version.json` `pathFilters` boundary: `references/release-publish-mechanics.md`. +- **A package push can fail after the release is already cut**, since it runs after the release + task and no gate covers it. A full re-run is always available inside its bounded + window and is the only route once the branch tip has moved: + `references/release-publish-mechanics.md`. - **Adding, dropping, or wiring a release target** (which leaf task, which artifact-naming contract, which seam a given output belongs to: a GitHub Release asset, a package-registry push, an image-registry push, a filesystem deploy, or a source-only repo with no build layer at all), 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 8bfd49a6..3ca50760 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 @@ -2,8 +2,9 @@ Full detail for the "Publishing" rules in `SKILL.md`. Load this when adding or removing a release target, wiring a new leaf build task, deciding where a build output belongs (a GitHub Release -asset, a package-registry push, an image push, a deploy), or setting up a wrapper repo that tracks -an upstream release, not for reading the release model's shape (the SKILL.md summary covers that). +asset, a package-registry push, an image push, a deploy), recovering a package push that failed +after the release was already cut, or setting up a wrapper repo that tracks an upstream release, +not for reading the release model's shape (the SKILL.md summary covers that). ## Reusable-task parameter contract @@ -128,6 +129,26 @@ NBGV git height and therefore `SemVer2`, and the next publish *does* create a fr even when the shipped binary is byte-identical. This is accepted NBGV behavior, and `pathFilters` are intentionally not added. +## Recovering a failed registry push + +A package publish job is gated like everything else, `needs:` the release-task call, so a failed build skips it. The **push inside it** is what no gate can reach, because it runs after the whole release task and therefore after `github-release`. `WORKFLOW.md` D4.5 names the two recovery routes and leaves their mechanics here. A rejected token exchange, a registry outage, or a trusted-publishing policy naming the wrong workflow file leaves a published release and tag for a version that never reached the registry. The recovery is a re-dispatch or a full re-run rather than a cleanup. **A full re-run is always available inside its window, and a re-dispatch only while the branch tip has not moved**, so the tip decides whether there is a choice at all rather than which route to take. What re-dispatch buys, where it is available, is that it outlives the re-run window. + +**Re-dispatch, available only while the tip has not moved.** A `workflow_dispatch` takes a ref rather than a commit, and D2.3 admits only `main` or `develop`, so what it builds is that branch's tip at dispatch time. While the tip is still the commit whose push failed, a re-dispatch rebuilds the same version and runs its push again, refreshing the release the way any dispatch does. + +This is a time-of-check-to-time-of-use race rather than a guarded operation: nothing compares the tip against the failed run, so a push landing between the two mints a new version instead of erroring, and the operator sees a green publish that left the failed version unpublished. Confirm the failed run's own head commit still equals the branch tip immediately before dispatching, reading it as `gh run view --json headSha` against `gh api repos/{owner}/{repo}/branches/` for the branch that run built rather than whichever branch is to hand. Where the two differ, or where the check is not worth making, prefer the re-run route, which is bound to that commit by construction, and fall back to re-dispatch only once the re-run window below has closed. + +**Re-run all jobs, available inside the window whatever the tip has done.** `gh run rerun ` replays the run under the original event's `GITHUB_SHA` and `GITHUB_REF` and re-executes every job rather than only the failed ones. The publisher pins the release task to that commit with `ref: ${{ github.sha }}`, so `get-version` recomputes the same version from the same commit and history, each build leaf checks out the `GitCommitId` that job emits, the package artifact D5.2 deleted is rebuilt and re-uploaded rather than missing when `publish-` downloads it, and that job retries the push it failed. The release itself needs nothing from the re-run, the failed run having already cut it, though on a dispatch-triggered run the re-run re-enters `github-release`, which refreshes the release per D4.4's dispatch leg and runs the `release-asset-*` delete with it per D5.2. A re-dispatch here would build the new tip instead, and NBGV derives the version from git height, so that is a further version and the one whose push failed never reaches the registry. + +Three qualifications come with the re-run route. + +- D4.4 and `WORKFLOW.md` 5B's S9 describe a re-run whose predecessor push **succeeded**, where the registry dedupes the second one. This is the case they do not cover, and its retried push is the first the registry ever receives for that version. +- GitHub offers a re-run only within **30 days** of the initial run, and a repository's own **log** retention setting can be shorter, so the usable window is the shorter of the two. This is the run's own retention and is unrelated to D5.4's `retention-days: 1`, which bounds an uploaded artifact rather than the run. +- **Re-run failed jobs** (`--failed`) does not serve here. D5.2's delete runs on the path that reaches this case, its gate being `!cancelled()` and the download having succeeded, so it has already removed the package artifact a `--failed` re-run would download, and only the full re-run rebuilds it. + +Past the window, a moved tip leaves that version with no route to the registry. The release and tag already name it, and removing them is not the answer: leave them, and let the next publish carry a later version, recording the gap in `HISTORY.md`, since the release body is regenerated on any later dispatch refresh and cannot hold the record. + +What no route settles in advance is whether the registry accepts the retried push. + ## Wrapper repos that track an upstream release A repo wrapping an upstream release uses the hub-hosted `check-upstream-version-task.yml`: a diff --git a/.agents/skills/python-codestyle/references/testing.md b/.agents/skills/python-codestyle/references/testing.md index 49a867c4..b4368a73 100644 --- a/.agents/skills/python-codestyle/references/testing.md +++ b/.agents/skills/python-codestyle/references/testing.md @@ -7,7 +7,7 @@ are in `references/profiles.md`. Use `pytest` with configuration in `[tool.pytest.ini_options]`. Default invocation: `uv run pytest`. -**Coverage.** Before creating or modifying `pyproject.toml`, read `WORKFLOW.md` D1.6 for the coverage obligations a build-profile repo with tests owes. +**Coverage.** A build-profile repository with tests declares **`pytest-cov`** among its test dependencies, a dev dependency group where the repository is a uv project and a `requirements*.txt` entry where it is on pip, and selects the coverage source in its own `pyproject.toml`, an `addopts` entry of `--cov=` in practice. CI adds `--cov-report=xml` to the invocation, so the repository owes the dependency and the selector rather than that flag. Both halves are load-bearing and they fail differently: without the dependency the CI run exits non-zero on an unrecognized argument, and with the dependency but no selector it measures nothing, writes no file, and exits zero. Leave the report at the repository root as `coverage.xml`, the one path CI names. `WORKFLOW.md` D1.6 owns the pipeline half, the upload and the check that fails when no report was written. - One test file per module under test, named `test_.py`. - Test functions named `test__`, descriptive and not numbered. diff --git a/.agents/skills/workflow-ci-contract/SKILL.md b/.agents/skills/workflow-ci-contract/SKILL.md index a6fe9ab2..a02a47d6 100644 --- a/.agents/skills/workflow-ci-contract/SKILL.md +++ b/.agents/skills/workflow-ci-contract/SKILL.md @@ -24,7 +24,7 @@ description: >- ## The Contract Text -`references/architecture.md`, `references/d-guarantees.md`, and `references/test-methodology.md` carry `WORKFLOW.md` sections 3, 4, and 5 whole, each as a generated include, so the pipeline's architecture, a guarantee's exact wording, and the audit-trace-probe procedure are each one read away rather than restated in full here. A defect in an include region is fixed in `WORKFLOW.md` and regenerated, never edited in this skill, per the `skill-lifecycle` Skill. `WORKFLOW.md` keeps sections 1, 2, and 6 itself, the applicability rule, the style-rule pointer, and the per-project-type walkthroughs that say which items go N/A per type, so read those there. +`references/architecture.md`, `references/d-guarantees.md`, and `references/test-methodology.md` carry `WORKFLOW.md` sections 3, 4, and 5 whole, each as a generated include, so the pipeline's architecture, a guarantee's exact wording, and the audit-trace-probe procedure are each one read away rather than restated in full here. A defect in an include region is fixed in `WORKFLOW.md` and regenerated, never edited in this skill, per the `skill-lifecycle` Skill. `WORKFLOW.md` keeps sections 1, 2, and 6 itself, the applicability rule, the style-rule pointer, and the per-project-type walkthroughs, which say which constructs each type adds, map each construct to the scenarios it reaches, and carry three rules for reading a row, one of which is about a repository declaring more than one type, so read those there. ## After Any Workflow Edit diff --git a/.agents/skills/workflow-ci-contract/references/architecture.md b/.agents/skills/workflow-ci-contract/references/architecture.md index cfd6598a..9f8e6736 100644 --- a/.agents/skills/workflow-ci-contract/references/architecture.md +++ b/.agents/skills/workflow-ci-contract/references/architecture.md @@ -34,7 +34,7 @@ Their CI is lint/validation only (editorconfig/EOL plus domain linters such as H - **Orchestration** is generic and forms the standardization baseline **at the job level**: the single-branch publisher, the `get-version`, `validate-release`, and `github-release` jobs, and the `changes -> smoke-build -> aggregator` shape of the PR workflow. These job *bodies* should not need per-repo edits. - **Build** is repo-owned in shape: the `build-` leaf tasks, whether this repo hosts them itself or reaches hub-hosted ones by pin. -- **What the repo curates** (by design, not a leak): the *list* of targets. This is **not** a byte-for-byte file carry. Adding or dropping a target edits the orchestrator's surface: the `enable_` inputs and the `build-` job + its `github-release` **and** `build-docker` `needs:` entries in the release task, **and** the `changes` paths-filter entry + output + the `smoke-build` enable-forward in the PR workflow, plus the separate `publish-` job for a package target. "Verbatim" applies to the `github-release` job and the version/publish-plan logic, not to the release task's job list or the paths-filter. Subsetting is symmetric: the same surface you trim to drop a target you extend to add a new one (e.g. a `release-asset--library` producer needs a new `enable_library` input, a `build-library` job, its two `needs:` entries, and a `library` paths-filter entry, output, and `smoke-build` enable-forward). +- **What the repo curates** (by design, not a leak): the *list* of targets. This is **not** a byte-for-byte file carry. Adding or dropping a target edits the orchestrator's surface: the `enable_` inputs and the `build-` job + its `github-release` **and** `build-docker` `needs:` entries in the release task, **and** the `changes` paths-filter entry + output + the `smoke-build` enable-forward in the PR workflow, plus the separate `publish-` job for a package target. "Verbatim" applies to the `github-release` job and the version/publish-plan logic, except that job's own `needs:` list, and never to the release task's job list or the paths-filter. Subsetting is symmetric: the same surface you trim to drop a target you extend to add a new one (e.g. a `release-asset--library` producer needs a new `enable_library` input, a `build-library` job, its two `needs:` entries, and a `library` paths-filter entry, output, and `smoke-build` enable-forward). ### The Seam Contract diff --git a/.agents/skills/workflow-ci-contract/references/d-guarantees.md b/.agents/skills/workflow-ci-contract/references/d-guarantees.md index f2c62610..c9e1f3e4 100644 --- a/.agents/skills/workflow-ci-contract/references/d-guarantees.md +++ b/.agents/skills/workflow-ci-contract/references/d-guarantees.md @@ -6,7 +6,7 @@ The section below is `WORKFLOW.md` section 4, whole. Which of its items bind a g -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**. +The required behaviors, organized by domain. Each is a **MUST**, and its `Output:` states what a conforming pipeline is required to hold. An `Output:` may be a behavior a run exhibits, or a property of the committed source such as a SHA-pinned action or a `retention-days:` setting, and the two kinds bind on the same terms. An item may also carry an `Input:`, where the guarantee turns on a particular trigger or state rather than on every run, a *Prevents:*, where the failure it rules out is not evident from the `Output:` itself, and an *Implication:* or a *Note:*, for a consequence and for a caveat. Applicability is `WORKFLOW.md` section 1's rule rather than a label's, so an item scoped to a repository shape says so in its own prose. A workflow that violates any *applicable* guarantee is **not operational**. ### D1 - PR Fast-Feedback (Smoke) @@ -15,7 +15,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as the o - **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` over a repo whose own pytest configuration selects what to measure) 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. The Python invocation carries a load-bearing detail of its own, an omission rather than a collision: it names the report format and selects nothing to measure. `pytest-cov` reports on what `--cov` selects, so `--cov-report=xml` on its own measures nothing, writes no file, and exits zero, which the best-effort upload then reads exactly as it reads a healthy run. A Python repo with tests therefore declares **`pytest-cov`** among its test dependencies, a dev dependency group where the repo is a uv project and a `requirements*.txt` entry where it is on pip, selects the coverage source in its own `pyproject.toml`, an `addopts` entry of `--cov=` in practice, and leaves the report at the repo root as `coverage.xml`, already the one path the upload step names. The validator **fails the test step when that file was not written**, since nothing downstream of it can tell an absent report from an uploaded one, so a repo that redirects the report through `[tool.coverage.xml]` reds the gate rather than uploading nothing from a green run. That step sits in the hub validator's Python leg, which runs where the repository root carries `pyproject.toml`, `tests/`, and a dependency manifest the leg can install from, being a committed `uv.lock` or a root `requirements*.txt`. Those are the two dependency mechanisms the hub's `spec/project-types.json` names, and the leg reads the tree for either rather than for the lockfile alone, so a pip-based Python repo with tests is served here rather than skipped. Tests are what this guarantee turns on: a repo carrying no tests for that type owes no coverage whatever its dependency mechanism, since a token and a `codecov.yml` would then gate on a report nothing produces. A `lint-only` profile for that type (per the hub's `registry/repos.json`) owes none either, whatever tests it carries, since nothing there is built or packaged to report on. Where the guarantee does not apply, 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/`, `coverage.xml`, 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.6 Coverage is reported to Codecov (C# and Python).** Input: a C# or Python repo that has tests for that type. Output: the validation job runs those tests under coverage collection (`dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`, leaving `--coverage-output` unset so each test project writes its own report rather than overwriting a shared one, or `pytest --cov-report=xml` over a repo whose own `pyproject.toml` selects what to measure) and a `codecov/codecov-action` step uploads the report, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). The Python leg **fails its test step when no report was written**, since nothing downstream of it can tell an absent report from an uploaded one. The C# leg renames each report to `coverage-.cobertura.xml` before the upload step reads the directory, `codecov-cli`'s own finder not matching the default name, and a repo owning its validator rather than calling the hub's owes that rename itself. `CODECOV_TOKEN` lives in the repo's **actions** and **dependabot** secret stores, the second because a run triggered by a Dependabot pull request reads the Dependabot store and the upload would otherwise skip silently on every bot pull request. A caller reaching the reusable validator across repositories names the secret it passes (`secrets:` with `CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}`), on its pull request path and its publisher path alike, because `secrets: inherit` is documented for a caller in the same organization or enterprise, which a personal account is not. A call by local path stays inside one repository, where the caller's own store is the one the callee reads, so `secrets: inherit` is available there instead of naming each secret. The repo ships a **`codecov.yml`** setting the project and patch statuses to **`informational: true`** so a coverage delta never gates a pull request, and excluding intentionally-untested, non-shipped code (an example or benchmark project) from the denominator via `ignore`, which a repo may override where its quality bar requires a threshold. Coverage output is a build artifact, so `.gitignore` excludes it. The C# invocation runs under **Microsoft.Testing.Platform**, and the runner declaration, package references, and version floor an MTP-based test project needs are `CODESTYLE.md`'s .NET side. The Python invocation needs **`pytest-cov`** and a coverage selector, which are `CODESTYLE.md`'s Python side. N/A for a repo carrying no tests for that type, and for a `lint-only` profile for it (per the hub's `registry/repos.json`). *Prevents: coverage silently going unreported, and a coverage regression blocking an unrelated pull request.* ### D2 - Input/State Validation at Entry @@ -38,13 +38,13 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as the o - **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 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 `WORKFLOW.md` section 3's "Output Seam by Destination" 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 or a re-run rather than a cleanup, and which of the two applies turns on whether the branch tip has moved. 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 while the tip is still the commit whose push failed. Once the tip has moved a re-dispatch builds the new tip instead. NBGV derives the version from git height, so that is a further version, and the version whose push failed never reaches the registry. **Re-run all jobs** (`gh run rerun `) is the recovery there. GitHub replays a run under the original event's `GITHUB_SHA` and `GITHUB_REF` and re-executes every job rather than only the failed ones, the publisher pins the release task to that commit with `ref: ${{ github.sha }}`, so `get-version` recomputes the version from the same commit and history and each build leaf checks out the `GitCommitId` `get-version` emits, the package artifact D5.2 deleted is rebuilt and re-uploaded rather than missing when `publish-` downloads it, and that job retries the push it failed. The release needs nothing from that re-run, the failed run having already cut it, so whether D4.4's release-create step refreshes or skips does not bear on the recovery. What no route settles in advance is whether the registry accepts the retried push. Three qualifications come with **Re-run all jobs**. D4.4 and `WORKFLOW.md` section 5B's S9 describe a re-run whose predecessor push **succeeded**, where the registry dedupes the second one, so this recovery is the case they do not cover and its retried push is the first the registry ever receives for that version. GitHub offers a re-run only within **30 days** of the initial run, past which a moved tip leaves that version with no route at all. And **Re-run failed jobs** (`--failed`) is unreliable here rather than unavailable. D5.2's delete runs on the path that reaches this case, its gate being `!cancelled()` and the download having succeeded, and it removes the package artifact a `--failed` re-run would download. D5.3 leaves that delete best-effort, so the artifact survives where that delete ran and failed, and `--failed` works in that case alone. +- **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** target, skipped rather than failed, still lets docker push. 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. The push itself is what no gate can cover, because it runs after the whole release task and therefore after `github-release`, for the trusted-publishing reason `WORKFLOW.md` section 3's "Output Seam by Destination" package-registry bullet gives, so a rejected token exchange, a registry outage, or a trusted-publishing policy naming the wrong workflow file leaves a published release and tag for a version that never reached the registry. The recovery is a re-dispatch or a full re-run rather than a cleanup. **A full re-run is always available inside its window and is the only route once the branch tip has moved.** The `Re-run failed jobs` shortcut is not a third route here, D5.2's delete having already removed the artifact it would download. `GOVERNANCE.md` "Release Model", and the skill it routes to, carry the mechanics of each route, how to choose, and the window. *Prevents: a partial publish, e.g. a Docker image pushed while .NET publish failed and no release was cut.* - **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 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 then finds nothing, so the recovery for a failed push is the re-dispatch or the full re-run D4.5 names, and D4.5 sets out how far that cost actually reaches. *Prevents: deleting freshly built assets on a no-op re-run, and stranding a downloaded artifact when the push it fed fails.* +- **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, narrowed by `inputs.expect_release_assets`. 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 then finds nothing, so the recovery for a failed push is one of the two routes D4.5 names, and `GOVERNANCE.md` "Release Model", with the skill it routes to, sets out how far that cost actually reaches. *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.* @@ -52,22 +52,22 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as the o ### D6 - Seam / Architecture Conformance -- **D6.1 Pattern handoff.** Output: the release job downloads by `pattern:`/`merge-multiple:`, not `artifact-ids:`. Targets upload `release-asset--`. Canonical for single-target. +- **D6.1 Pattern handoff.** Output: the release job downloads by `pattern:`/`merge-multiple:`, not `artifact-ids:`. **File** targets upload `release-asset--`, and a target contributing no file to the release (Docker, PyPI) uploads no `release-asset-*` of its own, per D4.3, whatever other transfer artifact it uploads. The `pattern:` download is canonical for a single-target repo too, which does not special-case itself to `artifact-ids:`. - **D6.2 Branch drives config.** Output: branch-derived config reads `inputs.branch`, never `github.ref_name`. - **D6.3 Branch-suffixed artifacts.** Output: artifact names are branch-suffixed so a branch's artifacts do not collide with another branch's. -- **D6.4 Target add/drop is consistent.** Output: adding or dropping a target updates **all** of: the `enable_` input, the `build-` job and its `github-release` and `build-docker` `needs:` entries, the `changes` paths-filter entry + output, and the `smoke-build` enable-forward (and, for a package target, the separate `publish-` job). The `github-release` job body stays verbatim. *Prevents: a partial subset that startup-fails on a missing leaf or never smoke-builds a target.* +- **D6.4 Target add/drop is consistent.** Output: adding or dropping a target updates **all** of: the `enable_` input, the `build-` job and its `github-release` and `build-docker` `needs:` entries, the `changes` paths-filter entry + output, the `smoke-build` enable-forward, and `expect_release_assets` where the change adds the first file target or drops the last (D4.3), plus, for a package target, the separate `publish-` job. Everything in the `github-release` job **except its `needs:` list** stays verbatim, and so does the version and publish-plan logic. "Verbatim" never reaches the surfaces this item requires editing, that `needs:` list, the release task's job list, and the paths-filter among them. *Prevents: a partial subset that startup-fails on a missing leaf or never smoke-builds a target.* ### D7 - Concurrency, Permissions, Safety - **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 A `github.event.inputs` boolean is compared as a string.** Output: a boolean read through `github.event.inputs.` is compared against `'true'`, since that context delivers every input as a string whatever the input's declared type. Comparing it against the boolean `true` as well is dead rather than defensive: an operand-type mismatch casts each side to a number, a non-numeric string casts to `NaN`, and `NaN` compares equal to nothing, so `github.event.inputs. == true` is false even on the run where the input arrived as `true`. The `inputs` context preserves the declared boolean on the `workflow_call` and `workflow_dispatch` paths alike, so an `inputs.` read is used directly, and a both-forms comparison there is redundant rather than wrong, which is why the hub's Docker build task comparing its `build-base` input in both forms is not a finding. A workflow carrying both trigger blocks declares each boolean input in both, since one declaration does not propagate to the other, while a boolean that only ever arrives by `workflow_call` is declared in that block alone. `smoke` is such a boolean, every hub task declaring it being `workflow_call`-only, which is why D1.3 writes the workflow-layer gate `!inputs.smoke` against the real boolean and the composite-action gate `inputs.smoke != 'true'` against a string, a composite action's inputs being strings whatever their caller passed. A job or step **output** is a string for the same reason and takes the same `== 'true'` rather than a bare truthiness test, since the string `'false'` is truthy. *Prevents: a dispatch-path string read as truthy, and a comparison against the boolean `true`, which can never fire, standing in for the one that can.* -- **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.* +- **D7.4 Optional-dependency chaining.** Output: a cross-job condition chaining across an **optional** dependency allowlists `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 - **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened` for **every** Dependabot tier including semver-major (the required checks are the gate, not the bump magnitude); dispatches `--squash`/`--merge` by the PR's base ref; disables on a maintainer-pushed `synchronize`; concurrency keyed on the **PR number**, not `github.ref`. *Prevents: two PRs colliding in auto-merge.* -- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source. Dependabot targets both branches, security PRs to default. +- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source. `.github/dependabot.yml` targets both branches, and security PRs go to the default branch. - **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it. The `main` pin push publishes via the release gate, while a `develop` pin does not auto-publish. It ships via a `develop` dispatch (prerelease) or the next promotion to `main`. The tracker's `bump-branch-prefix` + `branches` MUST match a merge-bot rule, one of the built-in `-` head/base pairs or a `rules` entry the caller passes, or auto-merge silently never fires. A tracker whose bump needs a human decision instead sets `auto-merge: false`, which prefixes the head so no merge-bot rule matches it, whatever `bump-branch-prefix` names. - **D8.4 An identity allowlist used as a gate fails loud.** Where a gate compares `github.actor` (or a PR author) against hard-coded bot identities, the non-matching branch on an otherwise-legitimate trigger **emits a `::warning::`** rather than falling through silently. Output: a run that declines to act on an unrecognized identity is visibly annotated. *Prevents: the App being renamed, replaced, or reinstalled under a new slug, after which the comparison quietly evaluates false and the gate stops firing, a green and silent run that looks identical to a healthy one.* The masking matters most where a second path hides the loss: a weekly schedule keeps publishing, so the only symptom is release *timeliness*, easily missed for months. Where the failure is self-announcing instead (the merge-bot simply stops merging, so bot PRs visibly pile up) an annotation is optional. Resolving the identity at run time (mint an App token, read `GET /app`) removes the hard-coded string entirely and is the escalation if an allowlist proves fragile in practice. @@ -78,7 +78,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as the o - **D9.1** Every action SHA-pinned with a version comment (sole exception: the documented lagging-tag tool). - **D9.2** File/workflow/job/step names follow the suffix rules. A ruleset-bound job's `name:` equals its ruleset `context:` (renamed together). - **D9.3** Bash `run:` blocks start `set -Eeuo pipefail`. Multi-line `if:` uses `>-`. -- **D9.4** Docker layer cache targets a registry tag, not `type=gha`; `cache-to` writes only the built branch's `buildcache-` and only on push, while `cache-from` reads both branches; multi-image repos use a per-image cache tag. +- **D9.4** Docker layer cache targets a registry tag, not `type=gha`. `cache-to` writes only the built branch's `:buildcache-` and only on push, while `cache-from` reads both branches. A multi-image repo varies the cache **repository** rather than the tag, `:buildcache-` per image, the tag alone being unable to distinguish two images. - **D9.5** Line endings follow `.editorconfig`. `WORKFLOW.md` section 4 keeps the D-guarantees, and the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/references/d-guarantees.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries this section whole as a generated include. diff --git a/.agents/skills/workflow-ci-contract/references/test-methodology.md b/.agents/skills/workflow-ci-contract/references/test-methodology.md index 1dd10c14..f7690a0a 100644 --- a/.agents/skills/workflow-ci-contract/references/test-methodology.md +++ b/.agents/skills/workflow-ci-contract/references/test-methodology.md @@ -18,7 +18,7 @@ Cite what each verdict rests on. That is `file:line` for a file in the audited r ### 5B. End-to-End Trace Scenarios (No Execution, Deterministic from the YAML) -For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the inputs and emit the predicted **run/skip + version + release + artifact-end-state** table, then compare to the expected. A scenario governing a construct the repo does not contain is N/A, per `WORKFLOW.md` section 1, and an absent trigger is such a construct: a dispatch-only publisher records S5, S6 and S9 N/A, since their push and schedule paths can never fire there. Minimum set: +For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the inputs and emit the predicted **run/skip + version + release + artifact-end-state** table, then compare to the expected. A scenario governing a construct the repo does not contain is N/A, per `WORKFLOW.md` section 1, and an absent trigger is such a construct. Each scenario's trigger belongs to one workflow, so read that workflow's own `on:` block rather than the repo's type: S1 to S4 the pull request workflow's, S5 to S10 the publisher's, S11 the upstream tracker's, and S12 and S13 the deploy workflow's. A publisher carrying only `workflow_dispatch` therefore records S5, S6 and S9 N/A, their push and schedule paths never firing there, and a repo with no publisher at all records S5 to S10 N/A together. Where a scenario's path runs through a workflow or composite action the repo only **calls**, trace that callee as the repo reaches it, read at the SHA the caller pins rather than at the callee's current default branch, which is the same evidence rule 5A states. Predicting from the callee's `main` predicts a table for YAML the audited repo never runs. A local (`./`) or self-repository (`$/`) call carries no pin of its own and runs at the workflow commit, so it is traced at whatever SHA the outermost pinning caller fixed. Minimum set: | # | Input | Expected output | Exercises | | --- | --- | --- | --- | diff --git a/.claude-plugin/fleet-skills/.source-digests/dotnet-codestyle b/.claude-plugin/fleet-skills/.source-digests/dotnet-codestyle index 42cefdcb..a221fb7b 100644 --- a/.claude-plugin/fleet-skills/.source-digests/dotnet-codestyle +++ b/.claude-plugin/fleet-skills/.source-digests/dotnet-codestyle @@ -1 +1 @@ -76063cfedec69d66 +770653a908016ec0 diff --git a/.claude-plugin/fleet-skills/.source-digests/operational-vs-release-workflow b/.claude-plugin/fleet-skills/.source-digests/operational-vs-release-workflow index b453545b..91bfe9e9 100644 --- a/.claude-plugin/fleet-skills/.source-digests/operational-vs-release-workflow +++ b/.claude-plugin/fleet-skills/.source-digests/operational-vs-release-workflow @@ -1 +1 @@ -7e4dace08c581494 +b65b6dc0582a9931 diff --git a/.claude-plugin/fleet-skills/.source-digests/python-codestyle b/.claude-plugin/fleet-skills/.source-digests/python-codestyle index 5d1b95e5..92acb4ee 100644 --- a/.claude-plugin/fleet-skills/.source-digests/python-codestyle +++ b/.claude-plugin/fleet-skills/.source-digests/python-codestyle @@ -1 +1 @@ -aba9474b9d63d774 +9304c99ac1f261db diff --git a/.claude-plugin/fleet-skills/.source-digests/workflow-ci-contract b/.claude-plugin/fleet-skills/.source-digests/workflow-ci-contract index ea463f09..4037fe61 100644 --- a/.claude-plugin/fleet-skills/.source-digests/workflow-ci-contract +++ b/.claude-plugin/fleet-skills/.source-digests/workflow-ci-contract @@ -1 +1 @@ -3d168164fc4e1f1a +a5a8b94de6cb7d3a diff --git a/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md b/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md index a6a32c03..7eb20897 100644 --- a/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/dotnet-codestyle/SKILL.md @@ -210,7 +210,7 @@ The .NET mechanics, narrowest first: xUnit v3 (`xunit.v3`, not the legacy `xunit`) + AwesomeAssertions (`.Should()` API, never native asserts). Arrange-Act-Assert pattern, descriptive underscore names, `[Theory]`/`[InlineData]` for -parameterized tests. See `references/testing.md` for the framework setup template. +parameterized tests. A test project on `xunit.v3` 4.0.0 or later is MTP-based, and also carries a `global.json` runner declaration, a `Microsoft.Testing.Extensions.CodeCoverage` floor, and no `xunit.runner.visualstudio`. See `references/testing.md` for the framework setup template and that configuration. ## Project configuration diff --git a/.claude-plugin/fleet-skills/skills/dotnet-codestyle/references/testing.md b/.claude-plugin/fleet-skills/skills/dotnet-codestyle/references/testing.md index 5a84a178..4ec0c4e6 100644 --- a/.claude-plugin/fleet-skills/skills/dotnet-codestyle/references/testing.md +++ b/.claude-plugin/fleet-skills/skills/dotnet-codestyle/references/testing.md @@ -23,3 +23,19 @@ 2. **Organization**: Arrange-Act-Assert pattern. 3. **Naming**: descriptive names with underscores. 4. **Theory tests**: use `[Theory]` with `[InlineData]`. + +## Microsoft.Testing.Platform and coverage + +A test project on `xunit.v3` 4.0.0 or later is MTP-based, and the .NET 10 SDK and later refuse to run one through the VSTest target, so such a project also carries: + +- a root **`global.json`** declaring `{"test": {"runner": "Microsoft.Testing.Platform"}}`, which is what selects the driver `dotnet test` runs the project through, +- **`Microsoft.Testing.Extensions.CodeCoverage`** at **18.9.0 or later**, in place of `coverlet.collector`, whose VSTest data collector MTP ignores without failing, +- no **`xunit.runner.visualstudio`**, the VSTest adapter MTP replaces. + +A project not yet MTP-based keeps the VSTest collector, and that lagging state is a migration owed rather than drift, until its own `xunit.v3` bump 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. + +The CI invocation `WORKFLOW.md` D1.6 requires is `dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`. Two further details of it 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 solution 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, so the report is renamed before the upload reads the directory, per `WORKFLOW.md` D1.6. + +**Diagnosing a local run.** `dotnet test` under the CI configuration reports zero tests on some machines where CI reports the full suite on the same SDK, which reads as a broken repository and is a broken driver. The target string the run prints separates the two: `net10.0` with no architecture means the driver resolved none, and `net10.0|` with no tests means the tests did not register, which is the case that points back at the three requirements above. diff --git a/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/SKILL.md b/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/SKILL.md index 00915587..5639d9f8 100644 --- a/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/operational-vs-release-workflow/SKILL.md @@ -106,10 +106,15 @@ rather than guessing from the repo's contents. `HISTORY.md`, and release notes name the version as `Version 1.0` (the floor), never the concrete build height, which is both wrong (the real height differs) and a maintenance trap. "Correcting" `1.0` to `1.0.0` is a defect. -- **A no-op publish (unchanged NBGV `SemVer2`) re-pushes nothing to any target keyed on the - version string, except Docker, which always re-pushes** to pick up upstream base-image +- **A no-op publish on a schedule or push trigger (unchanged NBGV `SemVer2`) re-pushes nothing to + any target keyed on the version string, except Docker, which always re-pushes** (a dispatch + refreshes the release instead of skipping) to pick up upstream base-image refreshes. Full guarantee and the `version.json` `pathFilters` boundary: `references/release-publish-mechanics.md`. +- **A package push can fail after the release is already cut**, since it runs after the release + task and no gate covers it. A full re-run is always available inside its bounded + window and is the only route once the branch tip has moved: + `references/release-publish-mechanics.md`. - **Adding, dropping, or wiring a release target** (which leaf task, which artifact-naming contract, which seam a given output belongs to: a GitHub Release asset, a package-registry push, an image-registry push, a filesystem deploy, or a source-only repo with no build layer at all), 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 8bfd49a6..3ca50760 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 @@ -2,8 +2,9 @@ Full detail for the "Publishing" rules in `SKILL.md`. Load this when adding or removing a release target, wiring a new leaf build task, deciding where a build output belongs (a GitHub Release -asset, a package-registry push, an image push, a deploy), or setting up a wrapper repo that tracks -an upstream release, not for reading the release model's shape (the SKILL.md summary covers that). +asset, a package-registry push, an image push, a deploy), recovering a package push that failed +after the release was already cut, or setting up a wrapper repo that tracks an upstream release, +not for reading the release model's shape (the SKILL.md summary covers that). ## Reusable-task parameter contract @@ -128,6 +129,26 @@ NBGV git height and therefore `SemVer2`, and the next publish *does* create a fr even when the shipped binary is byte-identical. This is accepted NBGV behavior, and `pathFilters` are intentionally not added. +## Recovering a failed registry push + +A package publish job is gated like everything else, `needs:` the release-task call, so a failed build skips it. The **push inside it** is what no gate can reach, because it runs after the whole release task and therefore after `github-release`. `WORKFLOW.md` D4.5 names the two recovery routes and leaves their mechanics here. A rejected token exchange, a registry outage, or a trusted-publishing policy naming the wrong workflow file leaves a published release and tag for a version that never reached the registry. The recovery is a re-dispatch or a full re-run rather than a cleanup. **A full re-run is always available inside its window, and a re-dispatch only while the branch tip has not moved**, so the tip decides whether there is a choice at all rather than which route to take. What re-dispatch buys, where it is available, is that it outlives the re-run window. + +**Re-dispatch, available only while the tip has not moved.** A `workflow_dispatch` takes a ref rather than a commit, and D2.3 admits only `main` or `develop`, so what it builds is that branch's tip at dispatch time. While the tip is still the commit whose push failed, a re-dispatch rebuilds the same version and runs its push again, refreshing the release the way any dispatch does. + +This is a time-of-check-to-time-of-use race rather than a guarded operation: nothing compares the tip against the failed run, so a push landing between the two mints a new version instead of erroring, and the operator sees a green publish that left the failed version unpublished. Confirm the failed run's own head commit still equals the branch tip immediately before dispatching, reading it as `gh run view --json headSha` against `gh api repos/{owner}/{repo}/branches/` for the branch that run built rather than whichever branch is to hand. Where the two differ, or where the check is not worth making, prefer the re-run route, which is bound to that commit by construction, and fall back to re-dispatch only once the re-run window below has closed. + +**Re-run all jobs, available inside the window whatever the tip has done.** `gh run rerun ` replays the run under the original event's `GITHUB_SHA` and `GITHUB_REF` and re-executes every job rather than only the failed ones. The publisher pins the release task to that commit with `ref: ${{ github.sha }}`, so `get-version` recomputes the same version from the same commit and history, each build leaf checks out the `GitCommitId` that job emits, the package artifact D5.2 deleted is rebuilt and re-uploaded rather than missing when `publish-` downloads it, and that job retries the push it failed. The release itself needs nothing from the re-run, the failed run having already cut it, though on a dispatch-triggered run the re-run re-enters `github-release`, which refreshes the release per D4.4's dispatch leg and runs the `release-asset-*` delete with it per D5.2. A re-dispatch here would build the new tip instead, and NBGV derives the version from git height, so that is a further version and the one whose push failed never reaches the registry. + +Three qualifications come with the re-run route. + +- D4.4 and `WORKFLOW.md` 5B's S9 describe a re-run whose predecessor push **succeeded**, where the registry dedupes the second one. This is the case they do not cover, and its retried push is the first the registry ever receives for that version. +- GitHub offers a re-run only within **30 days** of the initial run, and a repository's own **log** retention setting can be shorter, so the usable window is the shorter of the two. This is the run's own retention and is unrelated to D5.4's `retention-days: 1`, which bounds an uploaded artifact rather than the run. +- **Re-run failed jobs** (`--failed`) does not serve here. D5.2's delete runs on the path that reaches this case, its gate being `!cancelled()` and the download having succeeded, so it has already removed the package artifact a `--failed` re-run would download, and only the full re-run rebuilds it. + +Past the window, a moved tip leaves that version with no route to the registry. The release and tag already name it, and removing them is not the answer: leave them, and let the next publish carry a later version, recording the gap in `HISTORY.md`, since the release body is regenerated on any later dispatch refresh and cannot hold the record. + +What no route settles in advance is whether the registry accepts the retried push. + ## Wrapper repos that track an upstream release A repo wrapping an upstream release uses the hub-hosted `check-upstream-version-task.yml`: a diff --git a/.claude-plugin/fleet-skills/skills/python-codestyle/references/testing.md b/.claude-plugin/fleet-skills/skills/python-codestyle/references/testing.md index 49a867c4..b4368a73 100644 --- a/.claude-plugin/fleet-skills/skills/python-codestyle/references/testing.md +++ b/.claude-plugin/fleet-skills/skills/python-codestyle/references/testing.md @@ -7,7 +7,7 @@ are in `references/profiles.md`. Use `pytest` with configuration in `[tool.pytest.ini_options]`. Default invocation: `uv run pytest`. -**Coverage.** Before creating or modifying `pyproject.toml`, read `WORKFLOW.md` D1.6 for the coverage obligations a build-profile repo with tests owes. +**Coverage.** A build-profile repository with tests declares **`pytest-cov`** among its test dependencies, a dev dependency group where the repository is a uv project and a `requirements*.txt` entry where it is on pip, and selects the coverage source in its own `pyproject.toml`, an `addopts` entry of `--cov=` in practice. CI adds `--cov-report=xml` to the invocation, so the repository owes the dependency and the selector rather than that flag. Both halves are load-bearing and they fail differently: without the dependency the CI run exits non-zero on an unrecognized argument, and with the dependency but no selector it measures nothing, writes no file, and exits zero. Leave the report at the repository root as `coverage.xml`, the one path CI names. `WORKFLOW.md` D1.6 owns the pipeline half, the upload and the check that fails when no report was written. - One test file per module under test, named `test_.py`. - Test functions named `test__`, descriptive and not numbered. 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 a6fe9ab2..a02a47d6 100644 --- a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md @@ -24,7 +24,7 @@ description: >- ## The Contract Text -`references/architecture.md`, `references/d-guarantees.md`, and `references/test-methodology.md` carry `WORKFLOW.md` sections 3, 4, and 5 whole, each as a generated include, so the pipeline's architecture, a guarantee's exact wording, and the audit-trace-probe procedure are each one read away rather than restated in full here. A defect in an include region is fixed in `WORKFLOW.md` and regenerated, never edited in this skill, per the `skill-lifecycle` Skill. `WORKFLOW.md` keeps sections 1, 2, and 6 itself, the applicability rule, the style-rule pointer, and the per-project-type walkthroughs that say which items go N/A per type, so read those there. +`references/architecture.md`, `references/d-guarantees.md`, and `references/test-methodology.md` carry `WORKFLOW.md` sections 3, 4, and 5 whole, each as a generated include, so the pipeline's architecture, a guarantee's exact wording, and the audit-trace-probe procedure are each one read away rather than restated in full here. A defect in an include region is fixed in `WORKFLOW.md` and regenerated, never edited in this skill, per the `skill-lifecycle` Skill. `WORKFLOW.md` keeps sections 1, 2, and 6 itself, the applicability rule, the style-rule pointer, and the per-project-type walkthroughs, which say which constructs each type adds, map each construct to the scenarios it reaches, and carry three rules for reading a row, one of which is about a repository declaring more than one type, so read those there. ## After Any Workflow Edit diff --git a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/architecture.md b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/architecture.md index cfd6598a..9f8e6736 100644 --- a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/architecture.md +++ b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/architecture.md @@ -34,7 +34,7 @@ Their CI is lint/validation only (editorconfig/EOL plus domain linters such as H - **Orchestration** is generic and forms the standardization baseline **at the job level**: the single-branch publisher, the `get-version`, `validate-release`, and `github-release` jobs, and the `changes -> smoke-build -> aggregator` shape of the PR workflow. These job *bodies* should not need per-repo edits. - **Build** is repo-owned in shape: the `build-` leaf tasks, whether this repo hosts them itself or reaches hub-hosted ones by pin. -- **What the repo curates** (by design, not a leak): the *list* of targets. This is **not** a byte-for-byte file carry. Adding or dropping a target edits the orchestrator's surface: the `enable_` inputs and the `build-` job + its `github-release` **and** `build-docker` `needs:` entries in the release task, **and** the `changes` paths-filter entry + output + the `smoke-build` enable-forward in the PR workflow, plus the separate `publish-` job for a package target. "Verbatim" applies to the `github-release` job and the version/publish-plan logic, not to the release task's job list or the paths-filter. Subsetting is symmetric: the same surface you trim to drop a target you extend to add a new one (e.g. a `release-asset--library` producer needs a new `enable_library` input, a `build-library` job, its two `needs:` entries, and a `library` paths-filter entry, output, and `smoke-build` enable-forward). +- **What the repo curates** (by design, not a leak): the *list* of targets. This is **not** a byte-for-byte file carry. Adding or dropping a target edits the orchestrator's surface: the `enable_` inputs and the `build-` job + its `github-release` **and** `build-docker` `needs:` entries in the release task, **and** the `changes` paths-filter entry + output + the `smoke-build` enable-forward in the PR workflow, plus the separate `publish-` job for a package target. "Verbatim" applies to the `github-release` job and the version/publish-plan logic, except that job's own `needs:` list, and never to the release task's job list or the paths-filter. Subsetting is symmetric: the same surface you trim to drop a target you extend to add a new one (e.g. a `release-asset--library` producer needs a new `enable_library` input, a `build-library` job, its two `needs:` entries, and a `library` paths-filter entry, output, and `smoke-build` enable-forward). ### The Seam Contract 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 f2c62610..c9e1f3e4 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 @@ -6,7 +6,7 @@ The section below is `WORKFLOW.md` section 4, whole. Which of its items bind a g -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**. +The required behaviors, organized by domain. Each is a **MUST**, and its `Output:` states what a conforming pipeline is required to hold. An `Output:` may be a behavior a run exhibits, or a property of the committed source such as a SHA-pinned action or a `retention-days:` setting, and the two kinds bind on the same terms. An item may also carry an `Input:`, where the guarantee turns on a particular trigger or state rather than on every run, a *Prevents:*, where the failure it rules out is not evident from the `Output:` itself, and an *Implication:* or a *Note:*, for a consequence and for a caveat. Applicability is `WORKFLOW.md` section 1's rule rather than a label's, so an item scoped to a repository shape says so in its own prose. A workflow that violates any *applicable* guarantee is **not operational**. ### D1 - PR Fast-Feedback (Smoke) @@ -15,7 +15,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as the o - **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` over a repo whose own pytest configuration selects what to measure) 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. The Python invocation carries a load-bearing detail of its own, an omission rather than a collision: it names the report format and selects nothing to measure. `pytest-cov` reports on what `--cov` selects, so `--cov-report=xml` on its own measures nothing, writes no file, and exits zero, which the best-effort upload then reads exactly as it reads a healthy run. A Python repo with tests therefore declares **`pytest-cov`** among its test dependencies, a dev dependency group where the repo is a uv project and a `requirements*.txt` entry where it is on pip, selects the coverage source in its own `pyproject.toml`, an `addopts` entry of `--cov=` in practice, and leaves the report at the repo root as `coverage.xml`, already the one path the upload step names. The validator **fails the test step when that file was not written**, since nothing downstream of it can tell an absent report from an uploaded one, so a repo that redirects the report through `[tool.coverage.xml]` reds the gate rather than uploading nothing from a green run. That step sits in the hub validator's Python leg, which runs where the repository root carries `pyproject.toml`, `tests/`, and a dependency manifest the leg can install from, being a committed `uv.lock` or a root `requirements*.txt`. Those are the two dependency mechanisms the hub's `spec/project-types.json` names, and the leg reads the tree for either rather than for the lockfile alone, so a pip-based Python repo with tests is served here rather than skipped. Tests are what this guarantee turns on: a repo carrying no tests for that type owes no coverage whatever its dependency mechanism, since a token and a `codecov.yml` would then gate on a report nothing produces. A `lint-only` profile for that type (per the hub's `registry/repos.json`) owes none either, whatever tests it carries, since nothing there is built or packaged to report on. Where the guarantee does not apply, 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/`, `coverage.xml`, 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.6 Coverage is reported to Codecov (C# and Python).** Input: a C# or Python repo that has tests for that type. Output: the validation job runs those tests under coverage collection (`dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`, leaving `--coverage-output` unset so each test project writes its own report rather than overwriting a shared one, or `pytest --cov-report=xml` over a repo whose own `pyproject.toml` selects what to measure) and a `codecov/codecov-action` step uploads the report, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). The Python leg **fails its test step when no report was written**, since nothing downstream of it can tell an absent report from an uploaded one. The C# leg renames each report to `coverage-.cobertura.xml` before the upload step reads the directory, `codecov-cli`'s own finder not matching the default name, and a repo owning its validator rather than calling the hub's owes that rename itself. `CODECOV_TOKEN` lives in the repo's **actions** and **dependabot** secret stores, the second because a run triggered by a Dependabot pull request reads the Dependabot store and the upload would otherwise skip silently on every bot pull request. A caller reaching the reusable validator across repositories names the secret it passes (`secrets:` with `CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}`), on its pull request path and its publisher path alike, because `secrets: inherit` is documented for a caller in the same organization or enterprise, which a personal account is not. A call by local path stays inside one repository, where the caller's own store is the one the callee reads, so `secrets: inherit` is available there instead of naming each secret. The repo ships a **`codecov.yml`** setting the project and patch statuses to **`informational: true`** so a coverage delta never gates a pull request, and excluding intentionally-untested, non-shipped code (an example or benchmark project) from the denominator via `ignore`, which a repo may override where its quality bar requires a threshold. Coverage output is a build artifact, so `.gitignore` excludes it. The C# invocation runs under **Microsoft.Testing.Platform**, and the runner declaration, package references, and version floor an MTP-based test project needs are `CODESTYLE.md`'s .NET side. The Python invocation needs **`pytest-cov`** and a coverage selector, which are `CODESTYLE.md`'s Python side. N/A for a repo carrying no tests for that type, and for a `lint-only` profile for it (per the hub's `registry/repos.json`). *Prevents: coverage silently going unreported, and a coverage regression blocking an unrelated pull request.* ### D2 - Input/State Validation at Entry @@ -38,13 +38,13 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as the o - **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 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 `WORKFLOW.md` section 3's "Output Seam by Destination" 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 or a re-run rather than a cleanup, and which of the two applies turns on whether the branch tip has moved. 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 while the tip is still the commit whose push failed. Once the tip has moved a re-dispatch builds the new tip instead. NBGV derives the version from git height, so that is a further version, and the version whose push failed never reaches the registry. **Re-run all jobs** (`gh run rerun `) is the recovery there. GitHub replays a run under the original event's `GITHUB_SHA` and `GITHUB_REF` and re-executes every job rather than only the failed ones, the publisher pins the release task to that commit with `ref: ${{ github.sha }}`, so `get-version` recomputes the version from the same commit and history and each build leaf checks out the `GitCommitId` `get-version` emits, the package artifact D5.2 deleted is rebuilt and re-uploaded rather than missing when `publish-` downloads it, and that job retries the push it failed. The release needs nothing from that re-run, the failed run having already cut it, so whether D4.4's release-create step refreshes or skips does not bear on the recovery. What no route settles in advance is whether the registry accepts the retried push. Three qualifications come with **Re-run all jobs**. D4.4 and `WORKFLOW.md` section 5B's S9 describe a re-run whose predecessor push **succeeded**, where the registry dedupes the second one, so this recovery is the case they do not cover and its retried push is the first the registry ever receives for that version. GitHub offers a re-run only within **30 days** of the initial run, past which a moved tip leaves that version with no route at all. And **Re-run failed jobs** (`--failed`) is unreliable here rather than unavailable. D5.2's delete runs on the path that reaches this case, its gate being `!cancelled()` and the download having succeeded, and it removes the package artifact a `--failed` re-run would download. D5.3 leaves that delete best-effort, so the artifact survives where that delete ran and failed, and `--failed` works in that case alone. +- **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** target, skipped rather than failed, still lets docker push. 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. The push itself is what no gate can cover, because it runs after the whole release task and therefore after `github-release`, for the trusted-publishing reason `WORKFLOW.md` section 3's "Output Seam by Destination" package-registry bullet gives, so a rejected token exchange, a registry outage, or a trusted-publishing policy naming the wrong workflow file leaves a published release and tag for a version that never reached the registry. The recovery is a re-dispatch or a full re-run rather than a cleanup. **A full re-run is always available inside its window and is the only route once the branch tip has moved.** The `Re-run failed jobs` shortcut is not a third route here, D5.2's delete having already removed the artifact it would download. `GOVERNANCE.md` "Release Model", and the skill it routes to, carry the mechanics of each route, how to choose, and the window. *Prevents: a partial publish, e.g. a Docker image pushed while .NET publish failed and no release was cut.* - **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 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 then finds nothing, so the recovery for a failed push is the re-dispatch or the full re-run D4.5 names, and D4.5 sets out how far that cost actually reaches. *Prevents: deleting freshly built assets on a no-op re-run, and stranding a downloaded artifact when the push it fed fails.* +- **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, narrowed by `inputs.expect_release_assets`. 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 then finds nothing, so the recovery for a failed push is one of the two routes D4.5 names, and `GOVERNANCE.md` "Release Model", with the skill it routes to, sets out how far that cost actually reaches. *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.* @@ -52,22 +52,22 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as the o ### D6 - Seam / Architecture Conformance -- **D6.1 Pattern handoff.** Output: the release job downloads by `pattern:`/`merge-multiple:`, not `artifact-ids:`. Targets upload `release-asset--`. Canonical for single-target. +- **D6.1 Pattern handoff.** Output: the release job downloads by `pattern:`/`merge-multiple:`, not `artifact-ids:`. **File** targets upload `release-asset--`, and a target contributing no file to the release (Docker, PyPI) uploads no `release-asset-*` of its own, per D4.3, whatever other transfer artifact it uploads. The `pattern:` download is canonical for a single-target repo too, which does not special-case itself to `artifact-ids:`. - **D6.2 Branch drives config.** Output: branch-derived config reads `inputs.branch`, never `github.ref_name`. - **D6.3 Branch-suffixed artifacts.** Output: artifact names are branch-suffixed so a branch's artifacts do not collide with another branch's. -- **D6.4 Target add/drop is consistent.** Output: adding or dropping a target updates **all** of: the `enable_` input, the `build-` job and its `github-release` and `build-docker` `needs:` entries, the `changes` paths-filter entry + output, and the `smoke-build` enable-forward (and, for a package target, the separate `publish-` job). The `github-release` job body stays verbatim. *Prevents: a partial subset that startup-fails on a missing leaf or never smoke-builds a target.* +- **D6.4 Target add/drop is consistent.** Output: adding or dropping a target updates **all** of: the `enable_` input, the `build-` job and its `github-release` and `build-docker` `needs:` entries, the `changes` paths-filter entry + output, the `smoke-build` enable-forward, and `expect_release_assets` where the change adds the first file target or drops the last (D4.3), plus, for a package target, the separate `publish-` job. Everything in the `github-release` job **except its `needs:` list** stays verbatim, and so does the version and publish-plan logic. "Verbatim" never reaches the surfaces this item requires editing, that `needs:` list, the release task's job list, and the paths-filter among them. *Prevents: a partial subset that startup-fails on a missing leaf or never smoke-builds a target.* ### D7 - Concurrency, Permissions, Safety - **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 A `github.event.inputs` boolean is compared as a string.** Output: a boolean read through `github.event.inputs.` is compared against `'true'`, since that context delivers every input as a string whatever the input's declared type. Comparing it against the boolean `true` as well is dead rather than defensive: an operand-type mismatch casts each side to a number, a non-numeric string casts to `NaN`, and `NaN` compares equal to nothing, so `github.event.inputs. == true` is false even on the run where the input arrived as `true`. The `inputs` context preserves the declared boolean on the `workflow_call` and `workflow_dispatch` paths alike, so an `inputs.` read is used directly, and a both-forms comparison there is redundant rather than wrong, which is why the hub's Docker build task comparing its `build-base` input in both forms is not a finding. A workflow carrying both trigger blocks declares each boolean input in both, since one declaration does not propagate to the other, while a boolean that only ever arrives by `workflow_call` is declared in that block alone. `smoke` is such a boolean, every hub task declaring it being `workflow_call`-only, which is why D1.3 writes the workflow-layer gate `!inputs.smoke` against the real boolean and the composite-action gate `inputs.smoke != 'true'` against a string, a composite action's inputs being strings whatever their caller passed. A job or step **output** is a string for the same reason and takes the same `== 'true'` rather than a bare truthiness test, since the string `'false'` is truthy. *Prevents: a dispatch-path string read as truthy, and a comparison against the boolean `true`, which can never fire, standing in for the one that can.* -- **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.* +- **D7.4 Optional-dependency chaining.** Output: a cross-job condition chaining across an **optional** dependency allowlists `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 - **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened` for **every** Dependabot tier including semver-major (the required checks are the gate, not the bump magnitude); dispatches `--squash`/`--merge` by the PR's base ref; disables on a maintainer-pushed `synchronize`; concurrency keyed on the **PR number**, not `github.ref`. *Prevents: two PRs colliding in auto-merge.* -- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source. Dependabot targets both branches, security PRs to default. +- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source. `.github/dependabot.yml` targets both branches, and security PRs go to the default branch. - **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it. The `main` pin push publishes via the release gate, while a `develop` pin does not auto-publish. It ships via a `develop` dispatch (prerelease) or the next promotion to `main`. The tracker's `bump-branch-prefix` + `branches` MUST match a merge-bot rule, one of the built-in `-` head/base pairs or a `rules` entry the caller passes, or auto-merge silently never fires. A tracker whose bump needs a human decision instead sets `auto-merge: false`, which prefixes the head so no merge-bot rule matches it, whatever `bump-branch-prefix` names. - **D8.4 An identity allowlist used as a gate fails loud.** Where a gate compares `github.actor` (or a PR author) against hard-coded bot identities, the non-matching branch on an otherwise-legitimate trigger **emits a `::warning::`** rather than falling through silently. Output: a run that declines to act on an unrecognized identity is visibly annotated. *Prevents: the App being renamed, replaced, or reinstalled under a new slug, after which the comparison quietly evaluates false and the gate stops firing, a green and silent run that looks identical to a healthy one.* The masking matters most where a second path hides the loss: a weekly schedule keeps publishing, so the only symptom is release *timeliness*, easily missed for months. Where the failure is self-announcing instead (the merge-bot simply stops merging, so bot PRs visibly pile up) an annotation is optional. Resolving the identity at run time (mint an App token, read `GET /app`) removes the hard-coded string entirely and is the escalation if an allowlist proves fragile in practice. @@ -78,7 +78,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as the o - **D9.1** Every action SHA-pinned with a version comment (sole exception: the documented lagging-tag tool). - **D9.2** File/workflow/job/step names follow the suffix rules. A ruleset-bound job's `name:` equals its ruleset `context:` (renamed together). - **D9.3** Bash `run:` blocks start `set -Eeuo pipefail`. Multi-line `if:` uses `>-`. -- **D9.4** Docker layer cache targets a registry tag, not `type=gha`; `cache-to` writes only the built branch's `buildcache-` and only on push, while `cache-from` reads both branches; multi-image repos use a per-image cache tag. +- **D9.4** Docker layer cache targets a registry tag, not `type=gha`. `cache-to` writes only the built branch's `:buildcache-` and only on push, while `cache-from` reads both branches. A multi-image repo varies the cache **repository** rather than the tag, `:buildcache-` per image, the tag alone being unable to distinguish two images. - **D9.5** Line endings follow `.editorconfig`. `WORKFLOW.md` section 4 keeps the D-guarantees, and the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/references/d-guarantees.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries this section whole as a generated include. 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 1dd10c14..f7690a0a 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 @@ -18,7 +18,7 @@ Cite what each verdict rests on. That is `file:line` for a file in the audited r ### 5B. End-to-End Trace Scenarios (No Execution, Deterministic from the YAML) -For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the inputs and emit the predicted **run/skip + version + release + artifact-end-state** table, then compare to the expected. A scenario governing a construct the repo does not contain is N/A, per `WORKFLOW.md` section 1, and an absent trigger is such a construct: a dispatch-only publisher records S5, S6 and S9 N/A, since their push and schedule paths can never fire there. Minimum set: +For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the inputs and emit the predicted **run/skip + version + release + artifact-end-state** table, then compare to the expected. A scenario governing a construct the repo does not contain is N/A, per `WORKFLOW.md` section 1, and an absent trigger is such a construct. Each scenario's trigger belongs to one workflow, so read that workflow's own `on:` block rather than the repo's type: S1 to S4 the pull request workflow's, S5 to S10 the publisher's, S11 the upstream tracker's, and S12 and S13 the deploy workflow's. A publisher carrying only `workflow_dispatch` therefore records S5, S6 and S9 N/A, their push and schedule paths never firing there, and a repo with no publisher at all records S5 to S10 N/A together. Where a scenario's path runs through a workflow or composite action the repo only **calls**, trace that callee as the repo reaches it, read at the SHA the caller pins rather than at the callee's current default branch, which is the same evidence rule 5A states. Predicting from the callee's `main` predicts a table for YAML the audited repo never runs. A local (`./`) or self-repository (`$/`) call carries no pin of its own and runs at the workflow commit, so it is traced at whatever SHA the outermost pinning caller fixed. Minimum set: | # | Input | Expected output | Exercises | | --- | --- | --- | --- | diff --git a/.github/skills/dotnet-codestyle/SKILL.md b/.github/skills/dotnet-codestyle/SKILL.md index a6a32c03..7eb20897 100644 --- a/.github/skills/dotnet-codestyle/SKILL.md +++ b/.github/skills/dotnet-codestyle/SKILL.md @@ -210,7 +210,7 @@ The .NET mechanics, narrowest first: xUnit v3 (`xunit.v3`, not the legacy `xunit`) + AwesomeAssertions (`.Should()` API, never native asserts). Arrange-Act-Assert pattern, descriptive underscore names, `[Theory]`/`[InlineData]` for -parameterized tests. See `references/testing.md` for the framework setup template. +parameterized tests. A test project on `xunit.v3` 4.0.0 or later is MTP-based, and also carries a `global.json` runner declaration, a `Microsoft.Testing.Extensions.CodeCoverage` floor, and no `xunit.runner.visualstudio`. See `references/testing.md` for the framework setup template and that configuration. ## Project configuration diff --git a/.github/skills/dotnet-codestyle/references/testing.md b/.github/skills/dotnet-codestyle/references/testing.md index 5a84a178..4ec0c4e6 100644 --- a/.github/skills/dotnet-codestyle/references/testing.md +++ b/.github/skills/dotnet-codestyle/references/testing.md @@ -23,3 +23,19 @@ 2. **Organization**: Arrange-Act-Assert pattern. 3. **Naming**: descriptive names with underscores. 4. **Theory tests**: use `[Theory]` with `[InlineData]`. + +## Microsoft.Testing.Platform and coverage + +A test project on `xunit.v3` 4.0.0 or later is MTP-based, and the .NET 10 SDK and later refuse to run one through the VSTest target, so such a project also carries: + +- a root **`global.json`** declaring `{"test": {"runner": "Microsoft.Testing.Platform"}}`, which is what selects the driver `dotnet test` runs the project through, +- **`Microsoft.Testing.Extensions.CodeCoverage`** at **18.9.0 or later**, in place of `coverlet.collector`, whose VSTest data collector MTP ignores without failing, +- no **`xunit.runner.visualstudio`**, the VSTest adapter MTP replaces. + +A project not yet MTP-based keeps the VSTest collector, and that lagging state is a migration owed rather than drift, until its own `xunit.v3` bump 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. + +The CI invocation `WORKFLOW.md` D1.6 requires is `dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`. Two further details of it 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 solution 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, so the report is renamed before the upload reads the directory, per `WORKFLOW.md` D1.6. + +**Diagnosing a local run.** `dotnet test` under the CI configuration reports zero tests on some machines where CI reports the full suite on the same SDK, which reads as a broken repository and is a broken driver. The target string the run prints separates the two: `net10.0` with no architecture means the driver resolved none, and `net10.0|` with no tests means the tests did not register, which is the case that points back at the three requirements above. diff --git a/.github/skills/operational-vs-release-workflow/SKILL.md b/.github/skills/operational-vs-release-workflow/SKILL.md index 00915587..5639d9f8 100644 --- a/.github/skills/operational-vs-release-workflow/SKILL.md +++ b/.github/skills/operational-vs-release-workflow/SKILL.md @@ -106,10 +106,15 @@ rather than guessing from the repo's contents. `HISTORY.md`, and release notes name the version as `Version 1.0` (the floor), never the concrete build height, which is both wrong (the real height differs) and a maintenance trap. "Correcting" `1.0` to `1.0.0` is a defect. -- **A no-op publish (unchanged NBGV `SemVer2`) re-pushes nothing to any target keyed on the - version string, except Docker, which always re-pushes** to pick up upstream base-image +- **A no-op publish on a schedule or push trigger (unchanged NBGV `SemVer2`) re-pushes nothing to + any target keyed on the version string, except Docker, which always re-pushes** (a dispatch + refreshes the release instead of skipping) to pick up upstream base-image refreshes. Full guarantee and the `version.json` `pathFilters` boundary: `references/release-publish-mechanics.md`. +- **A package push can fail after the release is already cut**, since it runs after the release + task and no gate covers it. A full re-run is always available inside its bounded + window and is the only route once the branch tip has moved: + `references/release-publish-mechanics.md`. - **Adding, dropping, or wiring a release target** (which leaf task, which artifact-naming contract, which seam a given output belongs to: a GitHub Release asset, a package-registry push, an image-registry push, a filesystem deploy, or a source-only repo with no build layer at all), 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 8bfd49a6..3ca50760 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 @@ -2,8 +2,9 @@ Full detail for the "Publishing" rules in `SKILL.md`. Load this when adding or removing a release target, wiring a new leaf build task, deciding where a build output belongs (a GitHub Release -asset, a package-registry push, an image push, a deploy), or setting up a wrapper repo that tracks -an upstream release, not for reading the release model's shape (the SKILL.md summary covers that). +asset, a package-registry push, an image push, a deploy), recovering a package push that failed +after the release was already cut, or setting up a wrapper repo that tracks an upstream release, +not for reading the release model's shape (the SKILL.md summary covers that). ## Reusable-task parameter contract @@ -128,6 +129,26 @@ NBGV git height and therefore `SemVer2`, and the next publish *does* create a fr even when the shipped binary is byte-identical. This is accepted NBGV behavior, and `pathFilters` are intentionally not added. +## Recovering a failed registry push + +A package publish job is gated like everything else, `needs:` the release-task call, so a failed build skips it. The **push inside it** is what no gate can reach, because it runs after the whole release task and therefore after `github-release`. `WORKFLOW.md` D4.5 names the two recovery routes and leaves their mechanics here. A rejected token exchange, a registry outage, or a trusted-publishing policy naming the wrong workflow file leaves a published release and tag for a version that never reached the registry. The recovery is a re-dispatch or a full re-run rather than a cleanup. **A full re-run is always available inside its window, and a re-dispatch only while the branch tip has not moved**, so the tip decides whether there is a choice at all rather than which route to take. What re-dispatch buys, where it is available, is that it outlives the re-run window. + +**Re-dispatch, available only while the tip has not moved.** A `workflow_dispatch` takes a ref rather than a commit, and D2.3 admits only `main` or `develop`, so what it builds is that branch's tip at dispatch time. While the tip is still the commit whose push failed, a re-dispatch rebuilds the same version and runs its push again, refreshing the release the way any dispatch does. + +This is a time-of-check-to-time-of-use race rather than a guarded operation: nothing compares the tip against the failed run, so a push landing between the two mints a new version instead of erroring, and the operator sees a green publish that left the failed version unpublished. Confirm the failed run's own head commit still equals the branch tip immediately before dispatching, reading it as `gh run view --json headSha` against `gh api repos/{owner}/{repo}/branches/` for the branch that run built rather than whichever branch is to hand. Where the two differ, or where the check is not worth making, prefer the re-run route, which is bound to that commit by construction, and fall back to re-dispatch only once the re-run window below has closed. + +**Re-run all jobs, available inside the window whatever the tip has done.** `gh run rerun ` replays the run under the original event's `GITHUB_SHA` and `GITHUB_REF` and re-executes every job rather than only the failed ones. The publisher pins the release task to that commit with `ref: ${{ github.sha }}`, so `get-version` recomputes the same version from the same commit and history, each build leaf checks out the `GitCommitId` that job emits, the package artifact D5.2 deleted is rebuilt and re-uploaded rather than missing when `publish-` downloads it, and that job retries the push it failed. The release itself needs nothing from the re-run, the failed run having already cut it, though on a dispatch-triggered run the re-run re-enters `github-release`, which refreshes the release per D4.4's dispatch leg and runs the `release-asset-*` delete with it per D5.2. A re-dispatch here would build the new tip instead, and NBGV derives the version from git height, so that is a further version and the one whose push failed never reaches the registry. + +Three qualifications come with the re-run route. + +- D4.4 and `WORKFLOW.md` 5B's S9 describe a re-run whose predecessor push **succeeded**, where the registry dedupes the second one. This is the case they do not cover, and its retried push is the first the registry ever receives for that version. +- GitHub offers a re-run only within **30 days** of the initial run, and a repository's own **log** retention setting can be shorter, so the usable window is the shorter of the two. This is the run's own retention and is unrelated to D5.4's `retention-days: 1`, which bounds an uploaded artifact rather than the run. +- **Re-run failed jobs** (`--failed`) does not serve here. D5.2's delete runs on the path that reaches this case, its gate being `!cancelled()` and the download having succeeded, so it has already removed the package artifact a `--failed` re-run would download, and only the full re-run rebuilds it. + +Past the window, a moved tip leaves that version with no route to the registry. The release and tag already name it, and removing them is not the answer: leave them, and let the next publish carry a later version, recording the gap in `HISTORY.md`, since the release body is regenerated on any later dispatch refresh and cannot hold the record. + +What no route settles in advance is whether the registry accepts the retried push. + ## Wrapper repos that track an upstream release A repo wrapping an upstream release uses the hub-hosted `check-upstream-version-task.yml`: a diff --git a/.github/skills/python-codestyle/references/testing.md b/.github/skills/python-codestyle/references/testing.md index 49a867c4..b4368a73 100644 --- a/.github/skills/python-codestyle/references/testing.md +++ b/.github/skills/python-codestyle/references/testing.md @@ -7,7 +7,7 @@ are in `references/profiles.md`. Use `pytest` with configuration in `[tool.pytest.ini_options]`. Default invocation: `uv run pytest`. -**Coverage.** Before creating or modifying `pyproject.toml`, read `WORKFLOW.md` D1.6 for the coverage obligations a build-profile repo with tests owes. +**Coverage.** A build-profile repository with tests declares **`pytest-cov`** among its test dependencies, a dev dependency group where the repository is a uv project and a `requirements*.txt` entry where it is on pip, and selects the coverage source in its own `pyproject.toml`, an `addopts` entry of `--cov=` in practice. CI adds `--cov-report=xml` to the invocation, so the repository owes the dependency and the selector rather than that flag. Both halves are load-bearing and they fail differently: without the dependency the CI run exits non-zero on an unrecognized argument, and with the dependency but no selector it measures nothing, writes no file, and exits zero. Leave the report at the repository root as `coverage.xml`, the one path CI names. `WORKFLOW.md` D1.6 owns the pipeline half, the upload and the check that fails when no report was written. - One test file per module under test, named `test_.py`. - Test functions named `test__`, descriptive and not numbered. diff --git a/.github/skills/workflow-ci-contract/SKILL.md b/.github/skills/workflow-ci-contract/SKILL.md index a6fe9ab2..a02a47d6 100644 --- a/.github/skills/workflow-ci-contract/SKILL.md +++ b/.github/skills/workflow-ci-contract/SKILL.md @@ -24,7 +24,7 @@ description: >- ## The Contract Text -`references/architecture.md`, `references/d-guarantees.md`, and `references/test-methodology.md` carry `WORKFLOW.md` sections 3, 4, and 5 whole, each as a generated include, so the pipeline's architecture, a guarantee's exact wording, and the audit-trace-probe procedure are each one read away rather than restated in full here. A defect in an include region is fixed in `WORKFLOW.md` and regenerated, never edited in this skill, per the `skill-lifecycle` Skill. `WORKFLOW.md` keeps sections 1, 2, and 6 itself, the applicability rule, the style-rule pointer, and the per-project-type walkthroughs that say which items go N/A per type, so read those there. +`references/architecture.md`, `references/d-guarantees.md`, and `references/test-methodology.md` carry `WORKFLOW.md` sections 3, 4, and 5 whole, each as a generated include, so the pipeline's architecture, a guarantee's exact wording, and the audit-trace-probe procedure are each one read away rather than restated in full here. A defect in an include region is fixed in `WORKFLOW.md` and regenerated, never edited in this skill, per the `skill-lifecycle` Skill. `WORKFLOW.md` keeps sections 1, 2, and 6 itself, the applicability rule, the style-rule pointer, and the per-project-type walkthroughs, which say which constructs each type adds, map each construct to the scenarios it reaches, and carry three rules for reading a row, one of which is about a repository declaring more than one type, so read those there. ## After Any Workflow Edit diff --git a/.github/skills/workflow-ci-contract/references/architecture.md b/.github/skills/workflow-ci-contract/references/architecture.md index cfd6598a..9f8e6736 100644 --- a/.github/skills/workflow-ci-contract/references/architecture.md +++ b/.github/skills/workflow-ci-contract/references/architecture.md @@ -34,7 +34,7 @@ Their CI is lint/validation only (editorconfig/EOL plus domain linters such as H - **Orchestration** is generic and forms the standardization baseline **at the job level**: the single-branch publisher, the `get-version`, `validate-release`, and `github-release` jobs, and the `changes -> smoke-build -> aggregator` shape of the PR workflow. These job *bodies* should not need per-repo edits. - **Build** is repo-owned in shape: the `build-` leaf tasks, whether this repo hosts them itself or reaches hub-hosted ones by pin. -- **What the repo curates** (by design, not a leak): the *list* of targets. This is **not** a byte-for-byte file carry. Adding or dropping a target edits the orchestrator's surface: the `enable_` inputs and the `build-` job + its `github-release` **and** `build-docker` `needs:` entries in the release task, **and** the `changes` paths-filter entry + output + the `smoke-build` enable-forward in the PR workflow, plus the separate `publish-` job for a package target. "Verbatim" applies to the `github-release` job and the version/publish-plan logic, not to the release task's job list or the paths-filter. Subsetting is symmetric: the same surface you trim to drop a target you extend to add a new one (e.g. a `release-asset--library` producer needs a new `enable_library` input, a `build-library` job, its two `needs:` entries, and a `library` paths-filter entry, output, and `smoke-build` enable-forward). +- **What the repo curates** (by design, not a leak): the *list* of targets. This is **not** a byte-for-byte file carry. Adding or dropping a target edits the orchestrator's surface: the `enable_` inputs and the `build-` job + its `github-release` **and** `build-docker` `needs:` entries in the release task, **and** the `changes` paths-filter entry + output + the `smoke-build` enable-forward in the PR workflow, plus the separate `publish-` job for a package target. "Verbatim" applies to the `github-release` job and the version/publish-plan logic, except that job's own `needs:` list, and never to the release task's job list or the paths-filter. Subsetting is symmetric: the same surface you trim to drop a target you extend to add a new one (e.g. a `release-asset--library` producer needs a new `enable_library` input, a `build-library` job, its two `needs:` entries, and a `library` paths-filter entry, output, and `smoke-build` enable-forward). ### The Seam Contract diff --git a/.github/skills/workflow-ci-contract/references/d-guarantees.md b/.github/skills/workflow-ci-contract/references/d-guarantees.md index f2c62610..c9e1f3e4 100644 --- a/.github/skills/workflow-ci-contract/references/d-guarantees.md +++ b/.github/skills/workflow-ci-contract/references/d-guarantees.md @@ -6,7 +6,7 @@ The section below is `WORKFLOW.md` section 4, whole. Which of its items bind a g -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**. +The required behaviors, organized by domain. Each is a **MUST**, and its `Output:` states what a conforming pipeline is required to hold. An `Output:` may be a behavior a run exhibits, or a property of the committed source such as a SHA-pinned action or a `retention-days:` setting, and the two kinds bind on the same terms. An item may also carry an `Input:`, where the guarantee turns on a particular trigger or state rather than on every run, a *Prevents:*, where the failure it rules out is not evident from the `Output:` itself, and an *Implication:* or a *Note:*, for a consequence and for a caveat. Applicability is `WORKFLOW.md` section 1's rule rather than a label's, so an item scoped to a repository shape says so in its own prose. A workflow that violates any *applicable* guarantee is **not operational**. ### D1 - PR Fast-Feedback (Smoke) @@ -15,7 +15,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as the o - **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` over a repo whose own pytest configuration selects what to measure) 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. The Python invocation carries a load-bearing detail of its own, an omission rather than a collision: it names the report format and selects nothing to measure. `pytest-cov` reports on what `--cov` selects, so `--cov-report=xml` on its own measures nothing, writes no file, and exits zero, which the best-effort upload then reads exactly as it reads a healthy run. A Python repo with tests therefore declares **`pytest-cov`** among its test dependencies, a dev dependency group where the repo is a uv project and a `requirements*.txt` entry where it is on pip, selects the coverage source in its own `pyproject.toml`, an `addopts` entry of `--cov=` in practice, and leaves the report at the repo root as `coverage.xml`, already the one path the upload step names. The validator **fails the test step when that file was not written**, since nothing downstream of it can tell an absent report from an uploaded one, so a repo that redirects the report through `[tool.coverage.xml]` reds the gate rather than uploading nothing from a green run. That step sits in the hub validator's Python leg, which runs where the repository root carries `pyproject.toml`, `tests/`, and a dependency manifest the leg can install from, being a committed `uv.lock` or a root `requirements*.txt`. Those are the two dependency mechanisms the hub's `spec/project-types.json` names, and the leg reads the tree for either rather than for the lockfile alone, so a pip-based Python repo with tests is served here rather than skipped. Tests are what this guarantee turns on: a repo carrying no tests for that type owes no coverage whatever its dependency mechanism, since a token and a `codecov.yml` would then gate on a report nothing produces. A `lint-only` profile for that type (per the hub's `registry/repos.json`) owes none either, whatever tests it carries, since nothing there is built or packaged to report on. Where the guarantee does not apply, 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/`, `coverage.xml`, 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.6 Coverage is reported to Codecov (C# and Python).** Input: a C# or Python repo that has tests for that type. Output: the validation job runs those tests under coverage collection (`dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`, leaving `--coverage-output` unset so each test project writes its own report rather than overwriting a shared one, or `pytest --cov-report=xml` over a repo whose own `pyproject.toml` selects what to measure) and a `codecov/codecov-action` step uploads the report, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). The Python leg **fails its test step when no report was written**, since nothing downstream of it can tell an absent report from an uploaded one. The C# leg renames each report to `coverage-.cobertura.xml` before the upload step reads the directory, `codecov-cli`'s own finder not matching the default name, and a repo owning its validator rather than calling the hub's owes that rename itself. `CODECOV_TOKEN` lives in the repo's **actions** and **dependabot** secret stores, the second because a run triggered by a Dependabot pull request reads the Dependabot store and the upload would otherwise skip silently on every bot pull request. A caller reaching the reusable validator across repositories names the secret it passes (`secrets:` with `CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}`), on its pull request path and its publisher path alike, because `secrets: inherit` is documented for a caller in the same organization or enterprise, which a personal account is not. A call by local path stays inside one repository, where the caller's own store is the one the callee reads, so `secrets: inherit` is available there instead of naming each secret. The repo ships a **`codecov.yml`** setting the project and patch statuses to **`informational: true`** so a coverage delta never gates a pull request, and excluding intentionally-untested, non-shipped code (an example or benchmark project) from the denominator via `ignore`, which a repo may override where its quality bar requires a threshold. Coverage output is a build artifact, so `.gitignore` excludes it. The C# invocation runs under **Microsoft.Testing.Platform**, and the runner declaration, package references, and version floor an MTP-based test project needs are `CODESTYLE.md`'s .NET side. The Python invocation needs **`pytest-cov`** and a coverage selector, which are `CODESTYLE.md`'s Python side. N/A for a repo carrying no tests for that type, and for a `lint-only` profile for it (per the hub's `registry/repos.json`). *Prevents: coverage silently going unreported, and a coverage regression blocking an unrelated pull request.* ### D2 - Input/State Validation at Entry @@ -38,13 +38,13 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as the o - **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 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 `WORKFLOW.md` section 3's "Output Seam by Destination" 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 or a re-run rather than a cleanup, and which of the two applies turns on whether the branch tip has moved. 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 while the tip is still the commit whose push failed. Once the tip has moved a re-dispatch builds the new tip instead. NBGV derives the version from git height, so that is a further version, and the version whose push failed never reaches the registry. **Re-run all jobs** (`gh run rerun `) is the recovery there. GitHub replays a run under the original event's `GITHUB_SHA` and `GITHUB_REF` and re-executes every job rather than only the failed ones, the publisher pins the release task to that commit with `ref: ${{ github.sha }}`, so `get-version` recomputes the version from the same commit and history and each build leaf checks out the `GitCommitId` `get-version` emits, the package artifact D5.2 deleted is rebuilt and re-uploaded rather than missing when `publish-` downloads it, and that job retries the push it failed. The release needs nothing from that re-run, the failed run having already cut it, so whether D4.4's release-create step refreshes or skips does not bear on the recovery. What no route settles in advance is whether the registry accepts the retried push. Three qualifications come with **Re-run all jobs**. D4.4 and `WORKFLOW.md` section 5B's S9 describe a re-run whose predecessor push **succeeded**, where the registry dedupes the second one, so this recovery is the case they do not cover and its retried push is the first the registry ever receives for that version. GitHub offers a re-run only within **30 days** of the initial run, past which a moved tip leaves that version with no route at all. And **Re-run failed jobs** (`--failed`) is unreliable here rather than unavailable. D5.2's delete runs on the path that reaches this case, its gate being `!cancelled()` and the download having succeeded, and it removes the package artifact a `--failed` re-run would download. D5.3 leaves that delete best-effort, so the artifact survives where that delete ran and failed, and `--failed` works in that case alone. +- **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** target, skipped rather than failed, still lets docker push. 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. The push itself is what no gate can cover, because it runs after the whole release task and therefore after `github-release`, for the trusted-publishing reason `WORKFLOW.md` section 3's "Output Seam by Destination" package-registry bullet gives, so a rejected token exchange, a registry outage, or a trusted-publishing policy naming the wrong workflow file leaves a published release and tag for a version that never reached the registry. The recovery is a re-dispatch or a full re-run rather than a cleanup. **A full re-run is always available inside its window and is the only route once the branch tip has moved.** The `Re-run failed jobs` shortcut is not a third route here, D5.2's delete having already removed the artifact it would download. `GOVERNANCE.md` "Release Model", and the skill it routes to, carry the mechanics of each route, how to choose, and the window. *Prevents: a partial publish, e.g. a Docker image pushed while .NET publish failed and no release was cut.* - **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 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 then finds nothing, so the recovery for a failed push is the re-dispatch or the full re-run D4.5 names, and D4.5 sets out how far that cost actually reaches. *Prevents: deleting freshly built assets on a no-op re-run, and stranding a downloaded artifact when the push it fed fails.* +- **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, narrowed by `inputs.expect_release_assets`. 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 then finds nothing, so the recovery for a failed push is one of the two routes D4.5 names, and `GOVERNANCE.md` "Release Model", with the skill it routes to, sets out how far that cost actually reaches. *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.* @@ -52,22 +52,22 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as the o ### D6 - Seam / Architecture Conformance -- **D6.1 Pattern handoff.** Output: the release job downloads by `pattern:`/`merge-multiple:`, not `artifact-ids:`. Targets upload `release-asset--`. Canonical for single-target. +- **D6.1 Pattern handoff.** Output: the release job downloads by `pattern:`/`merge-multiple:`, not `artifact-ids:`. **File** targets upload `release-asset--`, and a target contributing no file to the release (Docker, PyPI) uploads no `release-asset-*` of its own, per D4.3, whatever other transfer artifact it uploads. The `pattern:` download is canonical for a single-target repo too, which does not special-case itself to `artifact-ids:`. - **D6.2 Branch drives config.** Output: branch-derived config reads `inputs.branch`, never `github.ref_name`. - **D6.3 Branch-suffixed artifacts.** Output: artifact names are branch-suffixed so a branch's artifacts do not collide with another branch's. -- **D6.4 Target add/drop is consistent.** Output: adding or dropping a target updates **all** of: the `enable_` input, the `build-` job and its `github-release` and `build-docker` `needs:` entries, the `changes` paths-filter entry + output, and the `smoke-build` enable-forward (and, for a package target, the separate `publish-` job). The `github-release` job body stays verbatim. *Prevents: a partial subset that startup-fails on a missing leaf or never smoke-builds a target.* +- **D6.4 Target add/drop is consistent.** Output: adding or dropping a target updates **all** of: the `enable_` input, the `build-` job and its `github-release` and `build-docker` `needs:` entries, the `changes` paths-filter entry + output, the `smoke-build` enable-forward, and `expect_release_assets` where the change adds the first file target or drops the last (D4.3), plus, for a package target, the separate `publish-` job. Everything in the `github-release` job **except its `needs:` list** stays verbatim, and so does the version and publish-plan logic. "Verbatim" never reaches the surfaces this item requires editing, that `needs:` list, the release task's job list, and the paths-filter among them. *Prevents: a partial subset that startup-fails on a missing leaf or never smoke-builds a target.* ### D7 - Concurrency, Permissions, Safety - **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 A `github.event.inputs` boolean is compared as a string.** Output: a boolean read through `github.event.inputs.` is compared against `'true'`, since that context delivers every input as a string whatever the input's declared type. Comparing it against the boolean `true` as well is dead rather than defensive: an operand-type mismatch casts each side to a number, a non-numeric string casts to `NaN`, and `NaN` compares equal to nothing, so `github.event.inputs. == true` is false even on the run where the input arrived as `true`. The `inputs` context preserves the declared boolean on the `workflow_call` and `workflow_dispatch` paths alike, so an `inputs.` read is used directly, and a both-forms comparison there is redundant rather than wrong, which is why the hub's Docker build task comparing its `build-base` input in both forms is not a finding. A workflow carrying both trigger blocks declares each boolean input in both, since one declaration does not propagate to the other, while a boolean that only ever arrives by `workflow_call` is declared in that block alone. `smoke` is such a boolean, every hub task declaring it being `workflow_call`-only, which is why D1.3 writes the workflow-layer gate `!inputs.smoke` against the real boolean and the composite-action gate `inputs.smoke != 'true'` against a string, a composite action's inputs being strings whatever their caller passed. A job or step **output** is a string for the same reason and takes the same `== 'true'` rather than a bare truthiness test, since the string `'false'` is truthy. *Prevents: a dispatch-path string read as truthy, and a comparison against the boolean `true`, which can never fire, standing in for the one that can.* -- **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.* +- **D7.4 Optional-dependency chaining.** Output: a cross-job condition chaining across an **optional** dependency allowlists `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 - **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened` for **every** Dependabot tier including semver-major (the required checks are the gate, not the bump magnitude); dispatches `--squash`/`--merge` by the PR's base ref; disables on a maintainer-pushed `synchronize`; concurrency keyed on the **PR number**, not `github.ref`. *Prevents: two PRs colliding in auto-merge.* -- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source. Dependabot targets both branches, security PRs to default. +- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source. `.github/dependabot.yml` targets both branches, and security PRs go to the default branch. - **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it. The `main` pin push publishes via the release gate, while a `develop` pin does not auto-publish. It ships via a `develop` dispatch (prerelease) or the next promotion to `main`. The tracker's `bump-branch-prefix` + `branches` MUST match a merge-bot rule, one of the built-in `-` head/base pairs or a `rules` entry the caller passes, or auto-merge silently never fires. A tracker whose bump needs a human decision instead sets `auto-merge: false`, which prefixes the head so no merge-bot rule matches it, whatever `bump-branch-prefix` names. - **D8.4 An identity allowlist used as a gate fails loud.** Where a gate compares `github.actor` (or a PR author) against hard-coded bot identities, the non-matching branch on an otherwise-legitimate trigger **emits a `::warning::`** rather than falling through silently. Output: a run that declines to act on an unrecognized identity is visibly annotated. *Prevents: the App being renamed, replaced, or reinstalled under a new slug, after which the comparison quietly evaluates false and the gate stops firing, a green and silent run that looks identical to a healthy one.* The masking matters most where a second path hides the loss: a weekly schedule keeps publishing, so the only symptom is release *timeliness*, easily missed for months. Where the failure is self-announcing instead (the merge-bot simply stops merging, so bot PRs visibly pile up) an annotation is optional. Resolving the identity at run time (mint an App token, read `GET /app`) removes the hard-coded string entirely and is the escalation if an allowlist proves fragile in practice. @@ -78,7 +78,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as the o - **D9.1** Every action SHA-pinned with a version comment (sole exception: the documented lagging-tag tool). - **D9.2** File/workflow/job/step names follow the suffix rules. A ruleset-bound job's `name:` equals its ruleset `context:` (renamed together). - **D9.3** Bash `run:` blocks start `set -Eeuo pipefail`. Multi-line `if:` uses `>-`. -- **D9.4** Docker layer cache targets a registry tag, not `type=gha`; `cache-to` writes only the built branch's `buildcache-` and only on push, while `cache-from` reads both branches; multi-image repos use a per-image cache tag. +- **D9.4** Docker layer cache targets a registry tag, not `type=gha`. `cache-to` writes only the built branch's `:buildcache-` and only on push, while `cache-from` reads both branches. A multi-image repo varies the cache **repository** rather than the tag, `:buildcache-` per image, the tag alone being unable to distinguish two images. - **D9.5** Line endings follow `.editorconfig`. `WORKFLOW.md` section 4 keeps the D-guarantees, and the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/references/d-guarantees.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries this section whole as a generated include. diff --git a/.github/skills/workflow-ci-contract/references/test-methodology.md b/.github/skills/workflow-ci-contract/references/test-methodology.md index 1dd10c14..f7690a0a 100644 --- a/.github/skills/workflow-ci-contract/references/test-methodology.md +++ b/.github/skills/workflow-ci-contract/references/test-methodology.md @@ -18,7 +18,7 @@ Cite what each verdict rests on. That is `file:line` for a file in the audited r ### 5B. End-to-End Trace Scenarios (No Execution, Deterministic from the YAML) -For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the inputs and emit the predicted **run/skip + version + release + artifact-end-state** table, then compare to the expected. A scenario governing a construct the repo does not contain is N/A, per `WORKFLOW.md` section 1, and an absent trigger is such a construct: a dispatch-only publisher records S5, S6 and S9 N/A, since their push and schedule paths can never fire there. Minimum set: +For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the inputs and emit the predicted **run/skip + version + release + artifact-end-state** table, then compare to the expected. A scenario governing a construct the repo does not contain is N/A, per `WORKFLOW.md` section 1, and an absent trigger is such a construct. Each scenario's trigger belongs to one workflow, so read that workflow's own `on:` block rather than the repo's type: S1 to S4 the pull request workflow's, S5 to S10 the publisher's, S11 the upstream tracker's, and S12 and S13 the deploy workflow's. A publisher carrying only `workflow_dispatch` therefore records S5, S6 and S9 N/A, their push and schedule paths never firing there, and a repo with no publisher at all records S5 to S10 N/A together. Where a scenario's path runs through a workflow or composite action the repo only **calls**, trace that callee as the repo reaches it, read at the SHA the caller pins rather than at the callee's current default branch, which is the same evidence rule 5A states. Predicting from the callee's `main` predicts a table for YAML the audited repo never runs. A local (`./`) or self-repository (`$/`) call carries no pin of its own and runs at the workflow commit, so it is traced at whatever SHA the outermost pinning caller fixed. Minimum set: | # | Input | Expected output | Exercises | | --- | --- | --- | --- | diff --git a/.husky/pre-commit b/.husky/pre-commit index c29f8a39..97274cc6 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -12,7 +12,7 @@ # `repo_gate.py --check sha-pin` is absent for a different reason. # It resolves same-owner pins against the GitHub API, and a hook needing a network fails offline. # The doc linters that need Docker stay in CI and in the VS Code Lint tasks. -set -e +set -eu # Git already runs a hook from the top level, measured by committing from `scripts/` and printing `pwd`. # This is belt and braces for an invocation that does not come from git. diff --git a/AUDIT.md b/AUDIT.md index 22b2f40e..7af0fb58 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -94,7 +94,7 @@ A check with `intentRef`/`workflowRef` points at the prose section that owns the ## 5. Assert the Actions Implement WORKFLOW.md -Run [`WORKFLOW.md`][workflow]'s methodology against the repo's **own** Actions, reading a workflow it only calls at the SHA it pins: the 5A static audit (structural facts per applicable D-guarantee, each cited in the form 5A sets out) and the 5B trace scenarios (predicted run/skip + version + release + artifact-end-state vs expected). The contract in WORKFLOW.md section 4 is satisfied by **outcome**, not by matching the catalog snippets in [`catalog/snippets/workflows/`][workflows] byte for byte. Those are the reference implementation, not required bytes. +Run [`WORKFLOW.md`][workflow]'s methodology against the repo's **own** Actions, reading a workflow it only calls at the SHA it pins: the 5A static audit (structural facts per applicable D-guarantee, each cited in the form 5A sets out) and the 5B trace scenarios (predicted run/skip + version + release + artifact-end-state vs expected). The contract in WORKFLOW.md section 4 is satisfied by **outcome**, not by matching the catalog snippets in [`catalog/snippets/workflows/`][workflows] byte for byte. Those are the reference implementation, not required bytes. Where a guarantee names a construct, D6.1's `release-asset--` and D9.2's ruleset-bound job `name:` among them, that name is the outcome and a divergence is a **defect** here. That is a separate judgment from the `verbatim` content hash section 0 describes, which classifies a mismatch as stale or modified and reports either at **drift**, since equivalence is intent-governed and a byte diff is a hint to review rather than a verdict. ## 6. Validate Settings, Rulesets, and Secrets diff --git a/CODESTYLE.md b/CODESTYLE.md index 9e478e32..9fb9f30b 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -37,7 +37,7 @@ These apply repo-wide, in every directory: Markdown lints clean via `markdownlin *This section applies only to the .NET side. A repo with no .NET projects still carries it (the file is carried whole) and ignores it.* -The style guide for any .NET projects in this repo: the zero-warnings build policy and its three-task clean-compile chain, central `Directory.Build.props`/`Directory.Packages.props` configuration, C# language and naming conventions, XML documentation, analyzer suppression scope, the library-versus-application logging split, async and error-handling patterns, xUnit v3 + AwesomeAssertions testing conventions, and AOT-compatible project configuration. +The style guide for any .NET projects in this repo: the zero-warnings build policy and its three-task clean-compile chain, central `Directory.Build.props`/`Directory.Packages.props` configuration, C# language and naming conventions, XML documentation, analyzer suppression scope, the library-versus-application logging split, async and error-handling patterns, xUnit v3 + AwesomeAssertions testing conventions, the runner declaration, package references and version floor that an MTP-based test project needs under `WORKFLOW.md` D1.6, with the local diagnostic for a run that reports no tests, and AOT-compatible project configuration. This is packaged as the `dotnet-codestyle` Skill at `.agents/skills/dotnet-codestyle/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. The summary above sketches the scope. Read the skill for the full rules, code examples, and mechanics. @@ -45,7 +45,7 @@ This is packaged as the `dotnet-codestyle` Skill at `.agents/skills/dotnet-codes *This section applies only to the Python side. A repo with no Python projects still carries it (the file is carried whole) and ignores it.* -The style guide for any Python project(s) in this repo: the build-versus-lint-only profile split, the uv/ruff/pyright/mypy/pytest toolchain, `src` layout, formatting and linting, comment and docstring conventions, type hints, naming, imports, patterns to avoid, test conventions, and versioning. +The style guide for any Python project(s) in this repo: the build-versus-lint-only profile split, the uv/ruff/pyright/mypy/pytest toolchain, `src` layout, formatting and linting, comment and docstring conventions, type hints, naming, imports, patterns to avoid, test conventions including the `pytest-cov` dependency and coverage selector a build-profile repo with tests owes under `WORKFLOW.md` D1.6, and versioning. This is packaged as the `python-codestyle` Skill at `.agents/skills/python-codestyle/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. The summary above sketches the scope. Read the skill for the full rules and the profile-adaptation guidance. diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 098ef4bd..5521bac7 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -71,8 +71,8 @@ dual-target bot wiring, and the operational-repo delta in full. The **two-phase model is the default**: PRs build fast, publishing is batched, a human merge never auto-publishes on its own. See [`WORKFLOW.md`](./WORKFLOW.md) for the full CI/CD contract. -Publishing fires on a manual dispatch, a code-affecting bot push to `main`, or (Docker only) a -weekly schedule, and versioning is semantic and maintainer-controlled (NBGV owns the build number, +Publishing fires on a manual dispatch, a code-affecting bot push to `main`, or a `main`-only +weekly schedule (Docker), and versioning is semantic and maintainer-controlled (NBGV owns the build number, the maintainer owns the `major.minor` floor). **Operational** repos differ, with a dispatch-only release and no auto-publish bots. See "Operational Repositories" below. @@ -80,7 +80,8 @@ This is packaged as part of the `operational-vs-release-workflow` Skill at `.agents/skills/operational-vs-release-workflow/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. The summary above sketches the contract. Read the skill for the full rules, including the release-target build layer, the -no-op republish guarantee, and wrapper-repo upstream-version tracking. +no-op republish guarantee, the recovery routes for a package push that fails after the release is +already cut, and wrapper-repo upstream-version tracking. ## Operational Repositories @@ -219,23 +220,23 @@ The provider-specific mechanics this contract needs to actually drive GitHub Cop ## Workflow YAML Conventions -These conventions describe the target state. New and modified workflows must respect them. The rest of the repo is expected to be brought up to the same standard. Sweep PRs that apply a rule everywhere are welcome when a rule changes. +These conventions bind every workflow. Several of them [`WORKFLOW.md`](./WORKFLOW.md) section 4 also states as guarantees, and not only at D9, so where it does, a violation of an *applicable* one is a defect that makes the workflow **not operational**, on the same terms as any other. Each D-item names its own constructs, so read section 4 for which rule binds where rather than a mapping kept here. The target-state framing below settles *when* an unswept workflow is fixed rather than *whether* its violation counts: new and modified workflows respect these rules now, and the rest of the repo is brought up to the same standard. Sweep PRs that apply a rule everywhere are welcome when a rule changes. -This section keeps the full rules and wins where [`WORKFLOW.md`](./WORKFLOW.md) overlaps it, and `WORKFLOW.md` section 2 points at it rather than restating it. The `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, surfaces it. +An overlap with `WORKFLOW.md` resolves **by subject**, never by blanket precedence. This section keeps the full style rules and wins on them, stating each in more detail than the guarantee that carries it, while `WORKFLOW.md` wins on the architecture, the contract, and the test methodology. `WORKFLOW.md` section 2 points at this section rather than restating it. Throughout, a job is named by its id and a step by its `name:`. The `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, surfaces it. -- **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 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". +- **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. This binds a `uses:` wherever it appears, in a workflow and in a composite action under `.github/actions/**` alike. 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**: a workflow declaring `on: workflow_call` ends in `-task.yml`, **whatever else it is also triggered by**, since that is the half the suffix is about. A workflow without `workflow_call` is an entry point (`push`, `pull_request`, `pull_request_target`, `schedule`, `workflow_dispatch`) and takes no `-task` suffix, ending instead with what it does: `-pull-request.yml`, `-release.yml`. The suffix says the file is meant to be `uses:`-d, which stays true of a file that is also dispatchable. Composite actions are named by their path (`.github/actions//action.yml`), so these suffix rules do not reach them. +- **Workflow `name:`** (the top-level `name:` field): a workflow declaring `workflow_call` takes a name ending in **"task"** (e.g. `Build project release task`), matching the filename rule above and covering a file that is also dispatchable, and every other workflow takes one ending in **"action"** (e.g. `Publish project release action`, `Test pull request action`). The suffix tells an orchestrator from a callee while reading the source tree, and on the runs list for an entry point. It does not do that in the Actions UI for a callee: a called reusable workflow's jobs appear nested inside the caller's run as ` / `, and the runs list shows the caller's workflow name rather than the callee's own. +- **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 trailing parenthetical qualifier after the suffix is allowed and is the only exception (`Upload coverage to Codecov step (Python)`), and nothing enforces the rule mechanically. A ruleset-bound job's `name:` and its ruleset `context:` are the **same string**: rename them **together**, or required-status-check enforcement silently breaks. Every surface whose staleness breaks that enforcement moves in the same change, never one without the others. In a repository the surfaces are the live ruleset and its own workflow. A rename of the fleet-wide string additionally moves the hub's `repo-config/` payloads, its `spec/files.json` `requiredCheckName`, and each adopter-facing stub in its `catalog/` and `docs/reusable-workflows.md`, which exist only in the hub. Prose naming the old string goes stale rather than breaking, and follows behind. +- **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. `cancel-in-progress: false` queues instead of cancelling, and queuing is not ordering: GitHub holds at most one pending run per group and cancels the previously pending one when a newer run queues, so the guarantee it buys is that a **running** job finishes rather than that every event runs in arrival order. **Documented exceptions**, each recording its rationale inline in its own header comment: (1) a merge-bot workflow 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 takes `cancel-in-progress: false`, because cancelling mid-flight would leave auto-merge enabled or disabled inconsistently (`.github/workflows/merge-bot-pull-request.yml`). (2) A publisher uses a **global, ref-independent group** (`group: ${{ github.workflow }}`, dropping the usual `-${{ github.ref }}`) with `cancel-in-progress: false`, because it publishes shared ref-independent outputs (both branches' Docker tags and caches, and GitHub releases) and its triggers need not agree on a ref, so a ref-scoped group would let two runs double-push, and cancelling one can leave a partially pushed tag set or a half-created release (`.github/workflows/publish-release.yml`). (3) A deploy workflow keys the group on the **environment** it deploys with `cancel-in-progress: false`, because a cancelled deploy leaves a release uploaded and the pointer unflipped. No workflow in this repository implements it, the deploy task being reusable rather than top-level, so the rationale lives here rather than in a header comment. +- **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. A deliberately POSIX `#!/bin/sh` surface, a git hook that must run before any toolchain exists being the case in practice, is not a bash surface: it takes `set -eu`, dropping `-E` and `pipefail`, which `sh` does not carry. +- **Conditionals**: multi-line `if:` uses the folded scalar `if: >-`, which joins the wrapped source lines back into one line. `WORKFLOW.md` D9.3 requires it. A literal block (`if: |`) evaluates the same, the expression lexer skipping newlines along with other whitespace, so this is a legibility rule rather than a correctness one, and it binds as a guarantee regardless. +- **Boolean inputs**: a workflow triggered both via `workflow_call` and `workflow_dispatch` declares each boolean input in *both* trigger blocks, since one declaration does not propagate to the other. Which context reads it then decides the comparison, and the two are not the same. The `inputs` context **preserves the declared boolean** on both paths, so `if: ${{ inputs.foo }}` is read directly. The `github.event.inputs` context delivers **every** input as a string whatever its declared type, so a read through it is compared against `'true'`. Comparing a `github.event.inputs` read against the boolean `true` as well is dead rather than defensive: an operand-type mismatch casts each side to a number, a non-numeric string casts to `NaN`, and `NaN` compares equal to nothing, so `github.event.inputs.foo == true` is false even on the run where the input arrived as `true`. A both-forms comparison on an `inputs` read is merely redundant. `WORKFLOW.md` D7.3 is the contract this bullet's rationale serves, and wins on any disagreement. +- **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** at entry, before any expensive build or publish work, rather than as partial checks scattered deep in later jobs. One gate that fails fast with a clear `::error::` beats a late or one-directional check. Where later **jobs** depend on the assertion, it is a job of its own that they `needs:`, since `needs:` takes job ids and cannot name a step. Where the work it guards is in the same job, an entry step in that job is enough. Examples: `build-release-task.yml`'s `validate-release` job (branch-versus-prerelease, both directions), and the input-validating entry step in `publish-docker-readme-task.yml`'s own first job, which also resolves the repository list, so a consumer of it depends on that job rather than on the validation alone. - **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')`, 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. +- **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. 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. Either `always()` or `!failure() && !cancelled()` serves, the explicit `success`/`skipped` allowlist beside it being what excludes a failed or cancelled dependency either way. They differ on a cancelled **run** and on a failed sibling `needs:` job, both of which `always()` still runs through. That is why `WORKFLOW.md` D1.5 requires `always()` of the pull request aggregator, which has to report a failed or skipped dependency rather than skip with it. +- **Artifact retention**: an explicitly uploaded workflow artifact is an intra-run handoff only, so it 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 has served its handoff once it has been downloaded, whether or not the push that followed succeeded. Recovering a failed push is a rebuild rather than a re-download, and "Release Model" above routes to what that costs. 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, and which the `retention-days: 1` backstop below never reaches, since it is set on an explicit upload step and an auto-emitted record has none, so they fall back to the repository's own retention period. 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 and 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. The cache is **asymmetric**: `cache-to` writes only on a push and only to the branch being built, so a pull request smoke run writes nothing at all and a `develop` publish writes only `buildcache-develop`, while `cache-from` reads both branches so a first build on a new branch still hits. A **multi-image** repo varies the cache **repository** rather than the tag, `:buildcache-` for each image, since one tag cannot distinguish two images. 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). ## Running the Linters Locally (Known-Working Invocations) diff --git a/WORKFLOW.md b/WORKFLOW.md index 0d4617f5..8f232812 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -1,31 +1,31 @@ # WORKFLOW.md -The guide for CI/CD **workflows** (GitHub Actions): a deliberate mixture of architecture, a **behavioral contract** (expected inputs and outputs), and a **test methodology**, the workflow style rules having their home in `GOVERNANCE.md`. Code style lives in [`CODESTYLE.md`][codestyle]. This file is its sibling for everything under `.github/workflows/`. +The guide for CI/CD **workflows** (GitHub Actions): a deliberate mixture of architecture, a **behavioral contract** (expected inputs and outputs), and a **test methodology**, the workflow style rules having their home in `GOVERNANCE.md`. Code style lives in [`CODESTYLE.md`][codestyle]. This file is its sibling for the pipeline those workflows implement, which reaches past `.github/workflows/` to every file and repository setting a guarantee names, `version.json` and a branch ruleset's `context:` string among them. -Its defining principle: **it describes required outcomes, not a required implementation.** Two repos may implement the same guarantee with different YAML. A workflow is correct when it **satisfies the contract** in section 4 and is **defect-free against the expected inputs and outputs**, not when it matches a reference implementation byte for byte. The style conventions that section 2 points at keep workflows legible. The contract in section 4 is what they must *do*. +Its defining principle: **it describes required outcomes.** Two repos may implement the same guarantee with different YAML wherever that guarantee names no construct, and where one is named, D6.1's `release-asset--` and D9.2's ruleset-bound job `name:` among them, matching it **is** the outcome. Section 4 is what a workflow must satisfy, and the verdict below is how that is judged. The style conventions that section 2 points at are part of that contract wherever section 4 states one as a guarantee, and a violation of one that it does is a defect on the same terms as any other. Given this document and the `GOVERNANCE.md` sections it points at, an agent must be able to do three things to any project: -1. **Audit** - statically check the workflows against the style conventions that section 2 points at and the structural facts each guarantee implies (section 5A). +1. **Audit** - statically check the structural fact each applicable guarantee implies, the style conventions among them (section 5A). 2. **Test** - trace the expected inputs/outputs (section 5B) and, where warranted, drive a live probe (section 5C). 3. **Assess** - render a verdict: **operational** (every *applicable* guarantee holds, every *applicable* scenario's predicted output equals the expected, and no 5C probe that was run contradicts either) or **not operational** (any *applicable* mismatch, which is a *defect*). -> **Canonical scope.** This document is authoritative for the workflow contract and test methodology (sections 3 to 6). The style conventions live in `GOVERNANCE.md` "Workflow YAML Conventions", which section 2 points at rather than restating, and the release policy in `GOVERNANCE.md` "Release Model". `GOVERNANCE.md` is authoritative wherever this document overlaps it, so on any such conflict it wins. +> **Canonical scope.** An overlap between this document and `GOVERNANCE.md` resolves **by subject**, never by blanket precedence. This document wins on the applicability and N/A rules (section 1), the pipeline architecture (section 3), the contract (section 4), the test methodology (section 5), and the per-project-type walkthroughs (section 6). `GOVERNANCE.md` wins on the workflow style conventions, at its "Workflow YAML Conventions", which section 2 points at rather than restating; on the release policy, at its "Release Model"; on the branching model, at its "Branching Model"; and on what an operational repo is, at its "Operational Repositories". Section 3 summarizes those last three rather than owning them. Each file names where it defers. 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 -- **Contract, not implementation.** Conform to the *outcomes* in section 4. Shape, job names, and file layout may differ between repos, but the input/output behavior may not. -- **Applicability.** A guarantee, or a 5B scenario, is **applicable** only if the repo contains the construct it governs: a given target, a transfer artifact, a registry push, a wrapper-version source. An item that governs an absent construct is **N/A**: record it as N/A and **exclude it from the verdict**. N/A is never a defect. Section 6 names which items go N/A per project type. A near-empty pipeline (source-only) is mostly N/A and that is fine. -- **Operational is binary.** A workflow is operational only if every *applicable* guarantee holds. A single applicable input/output mismatch is a defect and makes the workflow non-operational, regardless of how clean the YAML looks. +- **Contract, not implementation.** Conform to the *outcomes* in section 4. Shape, job names, and file layout may differ between repos wherever no guarantee names them, and where one does, as D9.2 does for the ruleset-bound job `name:`, that name is itself the outcome. +- **Applicability.** A guarantee, or a 5B scenario, is **applicable** only if the repo contains the construct it governs: a given target, a transfer artifact, a registry push, a wrapper-version source. An item that governs an absent construct is **N/A**: record it as N/A and **exclude it from the verdict**. N/A is never a defect. Section 6 names which constructs each project type adds: start from the constructs every repo has, its pull request workflow among them, **union what every declared type adds**, and record an item N/A only where nothing in that union supplies its construct. Which triggers a construct carries is read from the workflow itself rather than from the type, per section 6. Owning the release task rather than calling the hub-hosted copy changes where a construct's evidence is cited, per 5A, never whether it is applicable. A near-empty pipeline (source-only) is mostly N/A and that is fine. +- **Operational is binary.** A workflow is operational only where section 5's Assessment records all three of its conjuncts met. A single applicable failure is a defect and makes the workflow **not operational**, whether it is an input/output mismatch or a static property of the committed source such as an unpinned action SHA, regardless of how clean the YAML looks. - **Default branch.** Guarantees say "default branch" portably. It is implemented as the literal `main` in several places (the validate gate, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec`). These MUST all reference the repo's *actual* default branch. A divergence is a defect (D3.2). - **Two layers when auditing.** The pipeline splits into an **orchestrator** layer (the PR entry workflow, the publisher, and the version and release jobs) and a **build-leaf** layer (the `build-` tasks, whether separate files or jobs inside the release task). Inputs like `github`/`dockerhub`/`expect_release_assets` live on the orchestrator. A leaf receives `ref`/`branch`/`smoke` and whatever else its own target needs, a derived `push` among them where that leaf pushes. A package target declares no push input on either layer, because section 3's `Output Seam by Destination` puts its push in a `publish-` job in the publisher, gated by `needs:` rather than by a flag. Assert an input a guarantee names in the layer that declares it. - **The three verbs.** Audit (static), Test (trace + probe), Assess (verdict). Section 5 gives the exact procedure. ## 2. Workflow Style Conventions -`GOVERNANCE.md` "Workflow YAML Conventions" keeps the style rules, and this section points at it rather than restating it. Workflow YAML takes the same line-ending policy as every other file, which `GOVERNANCE.md` "Documentation Style Conventions" routes to under "Line Endings". Read the style rules before editing a workflow. They are cheap to check, necessary but not sufficient (a perfectly styled workflow can still violate section 4). +`GOVERNANCE.md` "Workflow YAML Conventions" keeps the style rules, and this section points at it rather than restating it. Workflow YAML takes the same line-ending policy as every other file, which `GOVERNANCE.md` "Documentation Style Conventions" routes to under "Line Endings". Read the style rules before editing a workflow. Section 4 carries several of them as guarantees of its own, and where it does, a violation is a defect rather than a nit. They are not sufficient on their own: a perfectly styled workflow can still violate the guarantees they do not cover. ## 3. Architecture @@ -57,7 +57,7 @@ Their CI is lint/validation only (editorconfig/EOL plus domain linters such as H - **Orchestration** is generic and forms the standardization baseline **at the job level**: the single-branch publisher, the `get-version`, `validate-release`, and `github-release` jobs, and the `changes -> smoke-build -> aggregator` shape of the PR workflow. These job *bodies* should not need per-repo edits. - **Build** is repo-owned in shape: the `build-` leaf tasks, whether this repo hosts them itself or reaches hub-hosted ones by pin. -- **What the repo curates** (by design, not a leak): the *list* of targets. This is **not** a byte-for-byte file carry. Adding or dropping a target edits the orchestrator's surface: the `enable_` inputs and the `build-` job + its `github-release` **and** `build-docker` `needs:` entries in the release task, **and** the `changes` paths-filter entry + output + the `smoke-build` enable-forward in the PR workflow, plus the separate `publish-` job for a package target. "Verbatim" applies to the `github-release` job and the version/publish-plan logic, not to the release task's job list or the paths-filter. Subsetting is symmetric: the same surface you trim to drop a target you extend to add a new one (e.g. a `release-asset--library` producer needs a new `enable_library` input, a `build-library` job, its two `needs:` entries, and a `library` paths-filter entry, output, and `smoke-build` enable-forward). +- **What the repo curates** (by design, not a leak): the *list* of targets. This is **not** a byte-for-byte file carry. Adding or dropping a target edits the orchestrator's surface: the `enable_` inputs and the `build-` job + its `github-release` **and** `build-docker` `needs:` entries in the release task, **and** the `changes` paths-filter entry + output + the `smoke-build` enable-forward in the PR workflow, plus the separate `publish-` job for a package target. "Verbatim" applies to the `github-release` job and the version/publish-plan logic, except that job's own `needs:` list, and never to the release task's job list or the paths-filter. Subsetting is symmetric: the same surface you trim to drop a target you extend to add a new one (e.g. a `release-asset--library` producer needs a new `enable_library` input, a `build-library` job, its two `needs:` entries, and a `library` paths-filter entry, output, and `smoke-build` enable-forward). ### The Seam Contract @@ -134,7 +134,7 @@ 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 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**. +The required behaviors, organized by domain. Each is a **MUST**, and its `Output:` states what a conforming pipeline is required to hold. An `Output:` may be a behavior a run exhibits, or a property of the committed source such as a SHA-pinned action or a `retention-days:` setting, and the two kinds bind on the same terms. An item may also carry an `Input:`, where the guarantee turns on a particular trigger or state rather than on every run, a *Prevents:*, where the failure it rules out is not evident from the `Output:` itself, and an *Implication:* or a *Note:*, for a consequence and for a caveat. Applicability is `WORKFLOW.md` section 1's rule rather than a label's, so an item scoped to a repository shape says so in its own prose. A workflow that violates any *applicable* guarantee is **not operational**. ### D1 - PR Fast-Feedback (Smoke) @@ -143,7 +143,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as the o - **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` over a repo whose own pytest configuration selects what to measure) 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. The Python invocation carries a load-bearing detail of its own, an omission rather than a collision: it names the report format and selects nothing to measure. `pytest-cov` reports on what `--cov` selects, so `--cov-report=xml` on its own measures nothing, writes no file, and exits zero, which the best-effort upload then reads exactly as it reads a healthy run. A Python repo with tests therefore declares **`pytest-cov`** among its test dependencies, a dev dependency group where the repo is a uv project and a `requirements*.txt` entry where it is on pip, selects the coverage source in its own `pyproject.toml`, an `addopts` entry of `--cov=` in practice, and leaves the report at the repo root as `coverage.xml`, already the one path the upload step names. The validator **fails the test step when that file was not written**, since nothing downstream of it can tell an absent report from an uploaded one, so a repo that redirects the report through `[tool.coverage.xml]` reds the gate rather than uploading nothing from a green run. That step sits in the hub validator's Python leg, which runs where the repository root carries `pyproject.toml`, `tests/`, and a dependency manifest the leg can install from, being a committed `uv.lock` or a root `requirements*.txt`. Those are the two dependency mechanisms the hub's `spec/project-types.json` names, and the leg reads the tree for either rather than for the lockfile alone, so a pip-based Python repo with tests is served here rather than skipped. Tests are what this guarantee turns on: a repo carrying no tests for that type owes no coverage whatever its dependency mechanism, since a token and a `codecov.yml` would then gate on a report nothing produces. A `lint-only` profile for that type (per the hub's `registry/repos.json`) owes none either, whatever tests it carries, since nothing there is built or packaged to report on. Where the guarantee does not apply, 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/`, `coverage.xml`, 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.6 Coverage is reported to Codecov (C# and Python).** Input: a C# or Python repo that has tests for that type. Output: the validation job runs those tests under coverage collection (`dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`, leaving `--coverage-output` unset so each test project writes its own report rather than overwriting a shared one, or `pytest --cov-report=xml` over a repo whose own `pyproject.toml` selects what to measure) and a `codecov/codecov-action` step uploads the report, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). The Python leg **fails its test step when no report was written**, since nothing downstream of it can tell an absent report from an uploaded one. The C# leg renames each report to `coverage-.cobertura.xml` before the upload step reads the directory, `codecov-cli`'s own finder not matching the default name, and a repo owning its validator rather than calling the hub's owes that rename itself. `CODECOV_TOKEN` lives in the repo's **actions** and **dependabot** secret stores, the second because a run triggered by a Dependabot pull request reads the Dependabot store and the upload would otherwise skip silently on every bot pull request. A caller reaching the reusable validator across repositories names the secret it passes (`secrets:` with `CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}`), on its pull request path and its publisher path alike, because `secrets: inherit` is documented for a caller in the same organization or enterprise, which a personal account is not. A call by local path stays inside one repository, where the caller's own store is the one the callee reads, so `secrets: inherit` is available there instead of naming each secret. The repo ships a **`codecov.yml`** setting the project and patch statuses to **`informational: true`** so a coverage delta never gates a pull request, and excluding intentionally-untested, non-shipped code (an example or benchmark project) from the denominator via `ignore`, which a repo may override where its quality bar requires a threshold. Coverage output is a build artifact, so `.gitignore` excludes it. The C# invocation runs under **Microsoft.Testing.Platform**, and the runner declaration, package references, and version floor an MTP-based test project needs are `CODESTYLE.md`'s .NET side. The Python invocation needs **`pytest-cov`** and a coverage selector, which are `CODESTYLE.md`'s Python side. N/A for a repo carrying no tests for that type, and for a `lint-only` profile for it (per the hub's `registry/repos.json`). *Prevents: coverage silently going unreported, and a coverage regression blocking an unrelated pull request.* ### D2 - Input/State Validation at Entry @@ -166,13 +166,13 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as the o - **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 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 `WORKFLOW.md` section 3's "Output Seam by Destination" 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 or a re-run rather than a cleanup, and which of the two applies turns on whether the branch tip has moved. 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 while the tip is still the commit whose push failed. Once the tip has moved a re-dispatch builds the new tip instead. NBGV derives the version from git height, so that is a further version, and the version whose push failed never reaches the registry. **Re-run all jobs** (`gh run rerun `) is the recovery there. GitHub replays a run under the original event's `GITHUB_SHA` and `GITHUB_REF` and re-executes every job rather than only the failed ones, the publisher pins the release task to that commit with `ref: ${{ github.sha }}`, so `get-version` recomputes the version from the same commit and history and each build leaf checks out the `GitCommitId` `get-version` emits, the package artifact D5.2 deleted is rebuilt and re-uploaded rather than missing when `publish-` downloads it, and that job retries the push it failed. The release needs nothing from that re-run, the failed run having already cut it, so whether D4.4's release-create step refreshes or skips does not bear on the recovery. What no route settles in advance is whether the registry accepts the retried push. Three qualifications come with **Re-run all jobs**. D4.4 and `WORKFLOW.md` section 5B's S9 describe a re-run whose predecessor push **succeeded**, where the registry dedupes the second one, so this recovery is the case they do not cover and its retried push is the first the registry ever receives for that version. GitHub offers a re-run only within **30 days** of the initial run, past which a moved tip leaves that version with no route at all. And **Re-run failed jobs** (`--failed`) is unreliable here rather than unavailable. D5.2's delete runs on the path that reaches this case, its gate being `!cancelled()` and the download having succeeded, and it removes the package artifact a `--failed` re-run would download. D5.3 leaves that delete best-effort, so the artifact survives where that delete ran and failed, and `--failed` works in that case alone. +- **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** target, skipped rather than failed, still lets docker push. 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. The push itself is what no gate can cover, because it runs after the whole release task and therefore after `github-release`, for the trusted-publishing reason `WORKFLOW.md` section 3's "Output Seam by Destination" package-registry bullet gives, so a rejected token exchange, a registry outage, or a trusted-publishing policy naming the wrong workflow file leaves a published release and tag for a version that never reached the registry. The recovery is a re-dispatch or a full re-run rather than a cleanup. **A full re-run is always available inside its window and is the only route once the branch tip has moved.** The `Re-run failed jobs` shortcut is not a third route here, D5.2's delete having already removed the artifact it would download. `GOVERNANCE.md` "Release Model", and the skill it routes to, carry the mechanics of each route, how to choose, and the window. *Prevents: a partial publish, e.g. a Docker image pushed while .NET publish failed and no release was cut.* - **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 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 then finds nothing, so the recovery for a failed push is the re-dispatch or the full re-run D4.5 names, and D4.5 sets out how far that cost actually reaches. *Prevents: deleting freshly built assets on a no-op re-run, and stranding a downloaded artifact when the push it fed fails.* +- **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, narrowed by `inputs.expect_release_assets`. 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 then finds nothing, so the recovery for a failed push is one of the two routes D4.5 names, and `GOVERNANCE.md` "Release Model", with the skill it routes to, sets out how far that cost actually reaches. *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.* @@ -180,22 +180,22 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as the o ### D6 - Seam / Architecture Conformance -- **D6.1 Pattern handoff.** Output: the release job downloads by `pattern:`/`merge-multiple:`, not `artifact-ids:`. Targets upload `release-asset--`. Canonical for single-target. +- **D6.1 Pattern handoff.** Output: the release job downloads by `pattern:`/`merge-multiple:`, not `artifact-ids:`. **File** targets upload `release-asset--`, and a target contributing no file to the release (Docker, PyPI) uploads no `release-asset-*` of its own, per D4.3, whatever other transfer artifact it uploads. The `pattern:` download is canonical for a single-target repo too, which does not special-case itself to `artifact-ids:`. - **D6.2 Branch drives config.** Output: branch-derived config reads `inputs.branch`, never `github.ref_name`. - **D6.3 Branch-suffixed artifacts.** Output: artifact names are branch-suffixed so a branch's artifacts do not collide with another branch's. -- **D6.4 Target add/drop is consistent.** Output: adding or dropping a target updates **all** of: the `enable_` input, the `build-` job and its `github-release` and `build-docker` `needs:` entries, the `changes` paths-filter entry + output, and the `smoke-build` enable-forward (and, for a package target, the separate `publish-` job). The `github-release` job body stays verbatim. *Prevents: a partial subset that startup-fails on a missing leaf or never smoke-builds a target.* +- **D6.4 Target add/drop is consistent.** Output: adding or dropping a target updates **all** of: the `enable_` input, the `build-` job and its `github-release` and `build-docker` `needs:` entries, the `changes` paths-filter entry + output, the `smoke-build` enable-forward, and `expect_release_assets` where the change adds the first file target or drops the last (D4.3), plus, for a package target, the separate `publish-` job. Everything in the `github-release` job **except its `needs:` list** stays verbatim, and so does the version and publish-plan logic. "Verbatim" never reaches the surfaces this item requires editing, that `needs:` list, the release task's job list, and the paths-filter among them. *Prevents: a partial subset that startup-fails on a missing leaf or never smoke-builds a target.* ### D7 - Concurrency, Permissions, Safety - **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 A `github.event.inputs` boolean is compared as a string.** Output: a boolean read through `github.event.inputs.` is compared against `'true'`, since that context delivers every input as a string whatever the input's declared type. Comparing it against the boolean `true` as well is dead rather than defensive: an operand-type mismatch casts each side to a number, a non-numeric string casts to `NaN`, and `NaN` compares equal to nothing, so `github.event.inputs. == true` is false even on the run where the input arrived as `true`. The `inputs` context preserves the declared boolean on the `workflow_call` and `workflow_dispatch` paths alike, so an `inputs.` read is used directly, and a both-forms comparison there is redundant rather than wrong, which is why the hub's Docker build task comparing its `build-base` input in both forms is not a finding. A workflow carrying both trigger blocks declares each boolean input in both, since one declaration does not propagate to the other, while a boolean that only ever arrives by `workflow_call` is declared in that block alone. `smoke` is such a boolean, every hub task declaring it being `workflow_call`-only, which is why D1.3 writes the workflow-layer gate `!inputs.smoke` against the real boolean and the composite-action gate `inputs.smoke != 'true'` against a string, a composite action's inputs being strings whatever their caller passed. A job or step **output** is a string for the same reason and takes the same `== 'true'` rather than a bare truthiness test, since the string `'false'` is truthy. *Prevents: a dispatch-path string read as truthy, and a comparison against the boolean `true`, which can never fire, standing in for the one that can.* -- **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.* +- **D7.4 Optional-dependency chaining.** Output: a cross-job condition chaining across an **optional** dependency allowlists `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 - **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened` for **every** Dependabot tier including semver-major (the required checks are the gate, not the bump magnitude); dispatches `--squash`/`--merge` by the PR's base ref; disables on a maintainer-pushed `synchronize`; concurrency keyed on the **PR number**, not `github.ref`. *Prevents: two PRs colliding in auto-merge.* -- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source. Dependabot targets both branches, security PRs to default. +- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source. `.github/dependabot.yml` targets both branches, and security PRs go to the default branch. - **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it. The `main` pin push publishes via the release gate, while a `develop` pin does not auto-publish. It ships via a `develop` dispatch (prerelease) or the next promotion to `main`. The tracker's `bump-branch-prefix` + `branches` MUST match a merge-bot rule, one of the built-in `-` head/base pairs or a `rules` entry the caller passes, or auto-merge silently never fires. A tracker whose bump needs a human decision instead sets `auto-merge: false`, which prefixes the head so no merge-bot rule matches it, whatever `bump-branch-prefix` names. - **D8.4 An identity allowlist used as a gate fails loud.** Where a gate compares `github.actor` (or a PR author) against hard-coded bot identities, the non-matching branch on an otherwise-legitimate trigger **emits a `::warning::`** rather than falling through silently. Output: a run that declines to act on an unrecognized identity is visibly annotated. *Prevents: the App being renamed, replaced, or reinstalled under a new slug, after which the comparison quietly evaluates false and the gate stops firing, a green and silent run that looks identical to a healthy one.* The masking matters most where a second path hides the loss: a weekly schedule keeps publishing, so the only symptom is release *timeliness*, easily missed for months. Where the failure is self-announcing instead (the merge-bot simply stops merging, so bot PRs visibly pile up) an annotation is optional. Resolving the identity at run time (mint an App token, read `GET /app`) removes the hard-coded string entirely and is the escalation if an allowlist proves fragile in practice. @@ -206,7 +206,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as the o - **D9.1** Every action SHA-pinned with a version comment (sole exception: the documented lagging-tag tool). - **D9.2** File/workflow/job/step names follow the suffix rules. A ruleset-bound job's `name:` equals its ruleset `context:` (renamed together). - **D9.3** Bash `run:` blocks start `set -Eeuo pipefail`. Multi-line `if:` uses `>-`. -- **D9.4** Docker layer cache targets a registry tag, not `type=gha`; `cache-to` writes only the built branch's `buildcache-` and only on push, while `cache-from` reads both branches; multi-image repos use a per-image cache tag. +- **D9.4** Docker layer cache targets a registry tag, not `type=gha`. `cache-to` writes only the built branch's `:buildcache-` and only on push, while `cache-from` reads both branches. A multi-image repo varies the cache **repository** rather than the tag, `:buildcache-` per image, the tag alone being unable to distinguish two images. - **D9.5** Line endings follow `.editorconfig`. `WORKFLOW.md` section 4 keeps the D-guarantees, and the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/references/d-guarantees.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries this section whole as a generated include. @@ -225,7 +225,7 @@ Cite what each verdict rests on. That is `file:line` for a file in the audited r ### 5B. End-to-End Trace Scenarios (No Execution, Deterministic from the YAML) -For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the inputs and emit the predicted **run/skip + version + release + artifact-end-state** table, then compare to the expected. A scenario governing a construct the repo does not contain is N/A, per `WORKFLOW.md` section 1, and an absent trigger is such a construct: a dispatch-only publisher records S5, S6 and S9 N/A, since their push and schedule paths can never fire there. Minimum set: +For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the inputs and emit the predicted **run/skip + version + release + artifact-end-state** table, then compare to the expected. A scenario governing a construct the repo does not contain is N/A, per `WORKFLOW.md` section 1, and an absent trigger is such a construct. Each scenario's trigger belongs to one workflow, so read that workflow's own `on:` block rather than the repo's type: S1 to S4 the pull request workflow's, S5 to S10 the publisher's, S11 the upstream tracker's, and S12 and S13 the deploy workflow's. A publisher carrying only `workflow_dispatch` therefore records S5, S6 and S9 N/A, their push and schedule paths never firing there, and a repo with no publisher at all records S5 to S10 N/A together. Where a scenario's path runs through a workflow or composite action the repo only **calls**, trace that callee as the repo reaches it, read at the SHA the caller pins rather than at the callee's current default branch, which is the same evidence rule 5A states. Predicting from the callee's `main` predicts a table for YAML the audited repo never runs. A local (`./`) or self-repository (`$/`) call carries no pin of its own and runs at the workflow commit, so it is traced at whatever SHA the outermost pinning caller fixed. Minimum set: | # | Input | Expected output | Exercises | | --- | --- | --- | --- | @@ -265,16 +265,41 @@ Record the workflow **operational** when every *applicable* 5A item passes, ever ## 6. Per-Project-Type Test Walkthroughs -Each type maps the *applicable* S-scenarios onto its targets. The differences are which leaf tasks exist and what each produces, and which guarantees and 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 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 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 guarantees of the NuGet, PyPI, Docker, and .NET publish targets, 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.1-D5.5, D6.1 and D6.3, and the guarantees of the .NET publish, NuGet, PyPI, Docker, and data-library targets 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 }}` (`GOVERNANCE.md` "Workflow YAML Conventions"). 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]. +Each type adds constructs to the pipeline, and the constructs are what decide a verdict. This section states what each type **adds**. Section 1's applicability rule turns that into the N/A set on its own: an item governing a construct the repo does not contain is N/A, and one governing a construct it contains is checked. No row here states an N/A list of its own, deliberately, so that reading one row can never take away a construct another row supplies. Walking the rows a repo's types select is the self-check that the contract holds for its shape. + +Three rules govern reading a row, each of them because reading one row alone has produced a wrong verdict. + +- **Types union, they do not choose.** A repo declaring more than one type contains the constructs of **every** type it declares, so an item is N/A only where no declared type supplies its construct. `source-only` beside another type is the case that catches a reader out: it adds no build target and takes none away. +- **The trigger scenarios come from the publisher's own `on:` block, not from the type.** S5, S6 and S9 turn on which triggers the publisher actually carries, and two repos of one type routinely differ there. Section 5B's preamble owns that rule and it binds here. +- **N/A names an absent construct, never an unexercised one.** The `pattern:` download (D6.1) and the cleanup jobs (D5.1 to D5.5) live in the release task, so a repo that owns that task and a repo that calls the hub-hosted copy both contain them and are both checked on them. Ownership decides only where the evidence is cited, in the repo's own file or at the SHA it pins, which is the form 5A gives. Section 5A requires an N/A verdict to name the construct that is absent, so an item that cannot be recorded that way is applicable. + +The table files each of S1 to S13 under exactly one row, which is what covers the set without a per-type list restating it. Filing is not the whole applicability test: a scenario is N/A when **any** construct it needs is absent, and that can be more than the row it sits under, S1 needing the pull request workflow as well as the build target. + +| Construct the repo contains | Scenarios it reaches | +| --- | --- | +| A pull request workflow with a validation job and the required aggregator | S2, S3 | +| A build target: a `changes` filter entry, a smoke build, and a leaf that builds it | S1, S4 | +| A publisher, meaning a workflow that cuts the release | S7, S8, S10, plus S5, S6 and S9 wherever its own `on:` admits a push or a schedule | +| An upstream-version tracker and its merge-bot | S11 | +| A deploy workflow targeting a filesystem on a host the project owns | S12, S13 | + +The rows below say which constructs a type brings with it. Read the repository for the rest: NBGV, `version.json` and the classification gate are reached on the smoke path too, so a repo with no publisher can still contain them, and a repo whose `releaseTrigger` is `none` has no publisher whatever its types say. + +What each type adds beyond that, and how its leaf behaves, is below. + +- **`dotnet-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 **strict, non-empty subset** of that runtime set and skips the archive and upload steps, so it uploads nothing. S1 smoke-builds that subset after a .NET project change. Where the repo has a publisher, 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 under the download step's own success (D5.2). Section 3's `Output Seam by Destination` 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`.** The leaf pushes the default branch multi-arch (amd64+arm64) and any other branch `amd64`-only, with a per-branch registry buildcache. A single-image repo caches to `:buildcache-`, and a multi-image repo varies the cache **repository** rather than the tag, `:buildcache-` for each image, since the tag alone cannot distinguish two images (`cache-to` writes only the built branch and only on push, `cache-from` reads both branches). It contributes 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`. Where the Docker Hub overview differs from the project README, the repo publishes a `Docker/README.md` through that task, the Hub description being size-limited. The docker-readme task validates its two mutually-exclusive input sources, `repositories` against `manifest`+`manifest-jq`, and defaults to the calling repository where neither is supplied, 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). 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. 5C Docker probe needs `DOCKER_HUB_*` secrets and same-repo (not fork) runs. +- **`library` (a worked example, not a fleet type).** No such leaf ships, so this row walks D6.4's add-a-target procedure rather than an existing shape: a single new leaf that validates, zips, and uploads `release-asset--library` (`retention-days: 1` per D5.4, upload gated on smoke being false per D1.3). Adding it means a new `enable_library` input, a `build-library` job and its `github-release` and `build-docker` `needs:` entries in the release task, and a `library` paths-filter entry, `changes` output, and `smoke-build` enable-forward in the PR workflow (without that last one, D1.1 never smoke-builds the library). **Only a repo that owns its release task can make the first half of that edit.** The hub-hosted release task declares a closed input set, so a repo calling it can add the paths-filter entry, the output, and the enable-forward in its own PR workflow and nothing else, and adding a target there is a change to the hub task first. 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` and the NBGV `get-version` **job** 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 skips release-create and the asset-delete (the existing zip is untouched, no registry push), while a `workflow_dispatch` re-run is D4.4's refresh case rather than S9's, re-uploading then re-deleting the asset. +- **`upstream-wrapper`.** The repo tracks an upstream project's releases rather than versioning its own code. A scheduled resolver writes a `name -> version` state file and opens a per-branch bump pull request, the merge-bot auto-merges it, or leaves it for the maintainer where the tracker sets `auto-merge: false` (D8.3), and the build leaf MUST read that state file for the immutable tag instead of `SemVer2` (the tracker ships without this consumer wiring). Test: S11 traces the bump from resolver to the publish that ships it, which is the `main` pin's next gated publish rather than the merge itself, since D4.1 admits no publish from a human merge. Nothing in this row depends on which build-target types the wrapper also declares. +- **`homeassistant`.** Adds a **file target**: distribution is a GitHub release that HACS installs from, so the release carries the integration zip. The contract's seam for that is D6.1's `release-asset--` upload collected by `pattern:`, which a repo owning its release task supplies itself, the hub-hosted task declaring a closed set of `enable_*` inputs that has no entry for this type. A repo reaching the same tag-plus-asset outcome through a differently-named artifact diverges from D4.3, D6.1 and D6.3 alike, which is a defect against the seam rather than against the release it produces. Its Python side follows home-assistant/core conventions rather than this fleet's defaults, pip with `requirements*.txt`, a `custom_components/` layout, and standalone `.ruff.toml` and `pyrightconfig.json`, which is expected rather than drift, and `mypy --strict` runs in CI. Test: S1 smoke-builds the zip, and S7 attaches it where the repo has a publisher. +- **`eda`.** Adds a **file target**: distribution is a GitHub release data zip that a local EDA install pulls, supplied by the repo's own release task for the same reason the `homeassistant` row gives. It also adds design-data validation to the pull request gate, the analogue of the code linters, `kicad-cli` ERC/DRC or library linting in practice. Where the repo generates build-time artifacts (gerbers, drill files, a BOM) that generation and its version injection are deterministic from the design inputs, which a data-only repo not yet building artifacts owes only once it starts, and such a repo has no leaf for S1 to smoke-build. Test: S2 and S3 cover the validation gate, and S7 attaches the zip where the repo has a publisher. +- **`codegen`.** Adds a generation workflow that runs as a matrix over both branches and is deterministic from an external source (D8.2), carrying no per-run timestamp or GUID. It contributes no build target and no publish scenario of its own, so it changes no row of the table above. +- **`csharp`, `python`, `cpp`, `docs`.** Add no pipeline construct. They decide what the validation job runs and what the repo's project configuration must hold, `csharp` the analyzer and central-MSBuild rules and D1.6's C# coverage leg, `python` the profile split and D1.6's Python one, `cpp` a shared `clang-format` feeding the lint gate, and `docs` a lint-only CI with no build or test. A repo declaring one of these and nothing else reaches only the scenarios its publisher and its pull request workflow already supply. +- **`source-only`.** Adds no build leaf of its own. In a repo declaring no build-target type beside it, the publish job reaches the reusable release task with `github: true`, every `enable_*` input false, and `expect_release_assets: false`, producing tag + source zip + README + LICENSE with no asset download, the paths-filter matches nothing so a retained `smoke-build` job is **structurally always skipped**, and the repo may drop that never-running job. A repo declaring a build-target type as well enables that target instead, this row taking nothing away from it. NBGV and `version.json` own the tag. 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. The publish job depends on the same reusable validation task the PR workflow runs, which prevents a dispatch from releasing a ref that fails validation. The release task carries the D6.1 `pattern:` download and the D5.1 to D5.5 cleanup, so a repo contains them whether it owns that task or calls the hub-hosted copy, and is checked on them either way. **Owning it changes only where the evidence is cited**, in the repo's own file or at the SHA it pins, per section 5A's citation rule. Test, where the repo has a publisher: S7 covers the release, S8 the dispatch guard, and S10 the classification gate. +- **`hugo` (a 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. 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`. That task declares the `environment:` inside itself rather than on the calling job, since GitHub rejects a job carrying both `uses:` and `environment:`, and because the task's own job is where the environment resolves, the caller maps the crossing secrets explicitly under `secrets:`: `DEPLOY_SSH_PRIVATE_KEY`, and the `SITE_AUTH_TOKEN_ID`/`SITE_AUTH_TOKEN` pair only where a token-gated live check needs it. `secrets: inherit` is not used on a cross-repository call, so the explicit mapping is what supplies the value the task's environment-bound job then reads. That task hard-asserts two interfaces. It requires the environment to carry `SITE_BASE_URL`, `DEPLOY_SSH_USER`, `DEPLOY_SSH_HOST` and `DEPLOY_SSH_KNOWN_HOSTS` as variables and `DEPLOY_SSH_PRIVATE_KEY` as a secret, each non-empty, failing fast naming every missing one, and it requires `SITE_AUTH_TOKEN_ID` and `SITE_AUTH_TOKEN` to be mapped together or not at all. And it requires a **deploy hook**, one composite action the caller owns, run three times for `build`, `prune` and `verify`, declaring all four of its inputs in its own `action.yml` because a composite action rejects an invocation supplying an input it does not declare. 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. The ref gate gets no counterpart re-assertion, so a caller reaching the task directly is trusted for the ref and not for the environment, which is a deliberate asymmetry rather than an omission. 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 the hook's `build` invocation, 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 hook's `prune` and `verify` invocations, the second checking the running host (D4.6) against the site's own URL contract. 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 in the `prune` hook, while a credential confined **write-only** can neither delete nor read back, so that repo's `prune` hook is a no-op, 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. +- **Operational (a `workflowModel`, not a type).** 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. 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 }}` (`GOVERNANCE.md` "Workflow YAML Conventions"). 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. Having no build target, such a repo reaches **S2 and S3** on every pull request, promotion and `develop` pull requests included, rather than S1, whose input is a target change. See the branch-model note in Section 3 and [GOVERNANCE.md "Branching Model"][governance-branching-model]. diff --git a/reports/canonical-review.json b/reports/canonical-review.json index b13fede1..ff4c4372 100644 --- a/reports/canonical-review.json +++ b/reports/canonical-review.json @@ -217,6 +217,14 @@ "hubCommit": "a2c9dab3bfde574d179b0765f9bb50f413ce7a80", "stamp": "2026-09-02T03:33:05Z" }, + { + "unit": ".agents/skills/dotnet-codestyle/SKILL.md > Testing conventions", + "digest": "sha256:8090b7a015bad6707b54bb9d3c2a62d40ca2d95b499a70f712668dbeb82f9275", + "reviewer": "agent-skill", + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:27Z" + }, { "unit": ".agents/skills/dotnet-codestyle/SKILL.md > Tooling and editor", "digest": "sha256:adaa0bb212c931d838d35dc9777b8ac5605ff46ccffc4448f227c78793085a57", @@ -225,6 +233,22 @@ "hubCommit": "68be56e977269486eeeca14bdc4982a0adbdb6b3", "stamp": "2026-09-02T13:41:40Z" }, + { + "unit": ".agents/skills/dotnet-codestyle/references/testing.md > (preamble)", + "digest": "sha256:2f477f9122d71f3070f272c5e9ba2e227482eff06a1463ca0ac5cef3d68ebcca", + "reviewer": "agent-skill", + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:27Z" + }, + { + "unit": ".agents/skills/dotnet-codestyle/references/testing.md > Microsoft.Testing.Platform and coverage", + "digest": "sha256:37d08392e883923be61ddfa87d05d268c41e565f1dccfa387d89727f7c654b90", + "reviewer": "agent-skill", + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:31:52Z" + }, { "unit": ".agents/skills/drive-pr/SKILL.md > (preamble)", "digest": "sha256:71a5535d07debc9fa5cd679296a83c096b9eef5280aa2b3fc8ae707b86c67335", @@ -337,6 +361,22 @@ "hubCommit": "eb6d2a055dc590113f515aed7cac1e019b7d2111", "stamp": "2026-09-03T18:32:16Z" }, + { + "unit": ".agents/skills/operational-vs-release-workflow/SKILL.md > Publishing (release model)", + "digest": "sha256:0274bfe3eba4544b3680c4fd25da9da161adcba4269345b2f08bb60ce3688730", + "reviewer": "agent-skill", + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:27Z" + }, + { + "unit": ".agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md > (preamble)", + "digest": "sha256:03b65b37ef0079e48a7628641dab7f0b79b326782127ed841928aa9235703e8b", + "reviewer": "agent-skill", + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:27Z" + }, { "unit": ".agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md > Map your outputs to the right seam", "digest": "sha256:42eb378e35d5810c91bfc40f3a56785f2fae5d6fe772b7ba9e9ed3c6b8a6d13c", @@ -369,6 +409,14 @@ "hubCommit": "37d116a2fa0cecf85a220c9005375091db240a8d", "stamp": "2026-09-01T14:33:08Z" }, + { + "unit": ".agents/skills/operational-vs-release-workflow/references/release-publish-mechanics.md > Recovering a failed registry push", + "digest": "sha256:78f378a68cb9bb4401ebb9815103a4a973a100e507c64eb9a62137c1c454c5b6", + "reviewer": "agent-skill", + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:31:52Z" + }, { "unit": ".agents/skills/pr-review-conduct/SKILL.md > Answering a suppressed finding", "digest": "sha256:7d1d9acd0646b16f9a41debbc44b97926a8bb43b515ab00e1298d32efb898fc7", @@ -427,11 +475,11 @@ }, { "unit": ".agents/skills/python-codestyle/references/testing.md", - "digest": "sha256:58de79a2634bd61f5d3d411b550b99537bba437a23a36f0e6995dd74ea67b1c2", + "digest": "sha256:94f61df9b261dbbce373b216b2393492ebd547778dee92d2bac61c892e30a00d", "reviewer": "agent-skill", - "findings": 0, - "hubCommit": "e56d99db54bd0d89f44904b7e76de366ca41c124", - "stamp": "2026-09-03T17:48:49Z" + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:31:52Z" }, { "unit": ".agents/skills/repo-worktree/SKILL.md > Creating a Worktree", @@ -539,11 +587,11 @@ }, { "unit": ".agents/skills/workflow-ci-contract/SKILL.md > The Contract Text", - "digest": "sha256:4a97612e6fc099699e97f76267186932eba13968557175e0b8534e493b247b1d", + "digest": "sha256:442d4210b0725f9df860f2a7c770c3a829e9bb28213f2c0f8aa2e27f13b11c31", "reviewer": "agent-skill", - "findings": 2, - "hubCommit": "0f9114282e9202db6f22a1ab23a9e8b73f02be11", - "stamp": "2026-09-06T17:51:06Z" + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:28Z" }, { "unit": ".agents/skills/workflow-ci-contract/SKILL.md > The Core Behavioral Spine", @@ -571,11 +619,11 @@ }, { "unit": ".agents/skills/workflow-ci-contract/references/architecture.md > The Architecture", - "digest": "sha256:32d3c4d8f85423bad4dc0066f8e93e90f27d1dcc172b0b06ec8d826031c132c7", + "digest": "sha256:aab6516648fdedbce7acaa83f91b0fea64accb7aa82a67e43a1f58a52e14649f", "reviewer": "agent-skill", - "findings": 2, - "hubCommit": "0f9114282e9202db6f22a1ab23a9e8b73f02be11", - "stamp": "2026-09-06T17:51:06Z" + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:28Z" }, { "unit": ".agents/skills/workflow-ci-contract/references/d-guarantees.md > (preamble)", @@ -587,11 +635,11 @@ }, { "unit": ".agents/skills/workflow-ci-contract/references/d-guarantees.md > The Behavioral Contract", - "digest": "sha256:c73a17c2f47b22d0a112c1712732c7b33488f77226327329f06191c7bb39b458", + "digest": "sha256:94614a4246ec43214b8043e09521dccd0932f5fa035cf6be3103902befa6d1d0", "reviewer": "agent-skill", - "findings": 5, - "hubCommit": "66d79460335379301db83fe6870303f8f701d92b", - "stamp": "2026-09-06T00:46:52Z" + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:28Z" }, { "unit": ".agents/skills/workflow-ci-contract/references/test-methodology.md > (preamble)", @@ -627,11 +675,11 @@ }, { "unit": ".agents/skills/workflow-ci-contract/references/test-methodology.md > The Test Methodology", - "digest": "sha256:5194ff2a1f7bb85ef300bddf83016e3d96807d2a5fed9533414cf928109f8037", + "digest": "sha256:37b196d79a98a292065b666a6be490736688646025e2f15d768034bff3725b00", "reviewer": "agent-skill", - "findings": 0, - "hubCommit": "2d63d52cbb8bd8a6d42846ddbdda18737c069187", - "stamp": "2026-09-06T20:18:39Z" + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:28Z" }, { "unit": ".agents/skills/workflow-ci-contract/references/test-methodology.md > Verdict", @@ -699,11 +747,11 @@ }, { "unit": "AUDIT.md > 5. Assert the Actions Implement WORKFLOW.md", - "digest": "sha256:bc28245e825a254487945189714d112ef5e5410e5967dcfd4ee79c2d3ce398f5", + "digest": "sha256:f4f013b448b1e2cf76519c624db36cecdc7288eb5235f077d708f4079df0d54c", "reviewer": "agent-skill", - "findings": 1, - "hubCommit": "2d63d52cbb8bd8a6d42846ddbdda18737c069187", - "stamp": "2026-09-06T19:30:43Z" + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:28Z" }, { "unit": "AUDIT.md > 6. Validate Settings, Rulesets, and Secrets", @@ -713,6 +761,14 @@ "hubCommit": "db9e5695f1501cc894953bdd57db46e4faf74159", "stamp": "2026-09-05T02:20:07Z" }, + { + "unit": "CODESTYLE.md > .NET", + "digest": "sha256:df1c9031b93a98e07f6b4ff3a211b61d9938e04b7fbf305d7a60384f02892e66", + "reviewer": "agent-skill", + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:28Z" + }, { "unit": "CODESTYLE.md > General", "digest": "sha256:54fcc07fc743507089e95ab8f1e7aeb64f0d83e31716514dadb666871a495684", @@ -721,6 +777,14 @@ "hubCommit": "112ffd874eaec3784a747b341fbdd0b19d75502c", "stamp": "2026-09-02T03:09:17Z" }, + { + "unit": "CODESTYLE.md > Python", + "digest": "sha256:6afed757bc277970b32d5b61b8850bd6282ebd19e86e7fa6588b8b2c66558db8", + "reviewer": "agent-skill", + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:28Z" + }, { "unit": "CODESTYLE.md > Shell", "digest": "sha256:7cf33c9cf26f21399d2de62d1219da67a16f28acfacf81cbe349948d502cb7ef", @@ -753,6 +817,14 @@ "hubCommit": "28dafdf46ee35cc9901e845a317991d909a94fac", "stamp": "2026-09-03T18:28:40Z" }, + { + "unit": "GOVERNANCE.md > Release Model", + "digest": "sha256:c06ac302758a576aa8f05777766a194e7e9da4756a821aa0bb43948e7ec25624", + "reviewer": "agent-skill", + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:28Z" + }, { "unit": "GOVERNANCE.md > Repository Layout", "digest": "sha256:b02bb961b5a0ac3b595b75cd01f6a9be91b5d6690a7f7b24bfe1f9c5660196b7", @@ -771,67 +843,67 @@ }, { "unit": "GOVERNANCE.md > Workflow YAML Conventions", - "digest": "sha256:dac3f1247b770bc2f4cee37c0ce794b01144c4193cf2ee29eda50a68fe4c194d", + "digest": "sha256:ab4254985a70c3cb81d8081b6dc493c225c3ffebf32f284e5b58b81a7609e409", "reviewer": "agent-skill", - "findings": 3, - "hubCommit": "66d79460335379301db83fe6870303f8f701d92b", - "stamp": "2026-09-06T04:40:33Z" + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:39:31Z" }, { "unit": "WORKFLOW.md > (preamble)", - "digest": "sha256:589cca940d663b7c69d821d82608c585b24e29636d9e893ba80c8fa0f6e92a05", + "digest": "sha256:2b31490cc52ca158dc3f4c089472d1f9a0fa8d9e0ac6d4ecd3e827fcbd18849b", "reviewer": "agent-skill", - "findings": 7, - "hubCommit": "7fcf612364d323ebab05fcdb319356c2b84864c3", - "stamp": "2026-09-06T16:26:38Z" + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:29Z" }, { "unit": "WORKFLOW.md > 1. Purpose and How to Use This Document", - "digest": "sha256:326b1daa6187f5675ddeefbd5d8bd6fb1510e861d158f152c794a88c689824c2", + "digest": "sha256:037855116da21001793779382cb2def5105fd8043f9ba5df203e8adb2d0e60f6", "reviewer": "agent-skill", - "findings": 0, - "hubCommit": "2d63d52cbb8bd8a6d42846ddbdda18737c069187", - "stamp": "2026-09-06T19:39:11Z" + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:29Z" }, { "unit": "WORKFLOW.md > 2. Workflow Style Conventions", - "digest": "sha256:eaeebbe227cc5d915c882471282c317b4c6ac62a258f6e9896c64b847f8f54cf", + "digest": "sha256:65d2e1c4353d1e4ce2ed4ec4b544718411b9b288143a8af4db57b7d0af4f9e97", "reviewer": "agent-skill", - "findings": 0, - "hubCommit": "66d79460335379301db83fe6870303f8f701d92b", - "stamp": "2026-09-06T04:45:08Z" + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:29Z" }, { "unit": "WORKFLOW.md > 3. Architecture", - "digest": "sha256:c0f4d188701cb09ca06f80831d40f4c0cecc8ca75b08fe44ac62aa27d2dd06e0", + "digest": "sha256:563658039c240fa0c4c437f54ccaee046be759ddceaa1c58d5d5f516595fa2c3", "reviewer": "agent-skill", - "findings": 10, - "hubCommit": "0f9114282e9202db6f22a1ab23a9e8b73f02be11", - "stamp": "2026-09-06T17:51:07Z" + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:29Z" }, { "unit": "WORKFLOW.md > 4. Behavioral Contract: Expected Outcomes", - "digest": "sha256:b16b89376b5740b15526d32c7392fe864756eb2ed1f4ba80a78a915c4b5682d4", + "digest": "sha256:5dc3d6cf3a20d039fc41f9cd26df5fb84f21d1a8f8b11dfdfd18fabf87e1db5a", "reviewer": "agent-skill", - "findings": 2, - "hubCommit": "66d79460335379301db83fe6870303f8f701d92b", - "stamp": "2026-09-06T00:46:50Z" + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:29Z" }, { "unit": "WORKFLOW.md > 5. Test Methodology", - "digest": "sha256:03e87dcc7b8a745aab8dccc8b2bd7558e948f880e31f1c063074dae9b2ebfd6f", + "digest": "sha256:98172b5a40068285b2b37666bfe85032168f78aa5f402f7cdb54caf2a083e941", "reviewer": "agent-skill", - "findings": 1, - "hubCommit": "2d63d52cbb8bd8a6d42846ddbdda18737c069187", - "stamp": "2026-09-06T20:18:39Z" + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:17:29Z" }, { "unit": "WORKFLOW.md > 6. Per-Project-Type Test Walkthroughs", - "digest": "sha256:740b093d556b31d42f5edb30c09c718c5a2f38d3c9eb87c7287743ae2537cb15", + "digest": "sha256:aa0bd2d323f81f253c79fab3ebd8d0015912aba1760233f7fcc6c3d79a958d16", "reviewer": "agent-skill", - "findings": 4, - "hubCommit": "2d63d52cbb8bd8a6d42846ddbdda18737c069187", - "stamp": "2026-09-06T21:02:39Z" + "findings": null, + "hubCommit": "88053791e5c76fbc4dc753ad3f21d3c3e6c80fcf", + "stamp": "2026-09-06T23:46:53Z" } ] }