Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ standardization baseline: `publish-release.yml` (single-branch publish plan), th
task plus `github-release` job inside `build-release-task.yml`, `get-version-task.yml`, and the
aggregator shape of `test-pull-request.yml`. Within
`test-pull-request.yml`, only the `changes -> smoke-build -> check-workflow-status` aggregator
wiring and the ruleset-bound job name are verbatim orchestration, while the `unit-test` job and
the `dorny/paths-filter` entries are owned/per-target. The **build** layer is a hook: a composite
wiring and the ruleset-bound job name are verbatim orchestration, while the `dorny/paths-filter`
entries are owned/per-target. The validation job is a call to the reusable validator, whose own
jobs a caller cannot address. The **build** layer is a hook: a composite
action at `.github/actions/build-<target>` the hub-hosted `build-release-task.yml` reaches. The
hub defaults require explicit project paths. A project needing more than a path override carries
its own hook.
Expand All @@ -53,7 +54,7 @@ looks tidier for 1:1 but forks the `github-release` download and breaks its verb

**What a repo still curates** (by design, not a leak): which `enable_<target>` inputs its caller
stub sets, per the per-target subsetting rule above. `build-release-task.yml` is hub-hosted
(`docs/reusable-workflows.md` "Stage 4: The Release Chain and the Docker Core"), so its job graph
(the hub's `docs/reusable-workflows.md` "Stage 4: The Release Chain and the Docker Core"), so its job graph
and its `github-release` job are the hub's, not a per-repo file a caller edits. A repo adopting the
release chain carries only the caller stub in its own `publish-release.yml` and
`test-pull-request.yml`, naming the hub task by pin and setting the `enable_*`, `docker_image`,
Expand Down Expand Up @@ -116,9 +117,9 @@ version/tag, which is heavyweight but expected even for a non-.NET repo, and acc

## No-op republish guarantee

A weekly/dispatch publish where NBGV `SemVer2` is **unchanged** (no new commit since the last
A scheduled or push publish where NBGV `SemVer2` is **unchanged** (no new commit since the last
publish) re-pushes **nothing** to GitHub Releases (the `github-release` job's `release-exists`
check skips the create step), NuGet (`dotnet nuget push --skip-duplicate`), or PyPI
check skips the create step, and a dispatch refreshes the release instead of skipping), NuGet (`dotnet nuget push --skip-duplicate`), or PyPI
(`gh-action-pypi-publish` `skip-existing: true`), since all three key on the version string.
**Docker always re-pushes** by design: it picks up upstream base-image refreshes (e.g.
`ubuntu:rolling`) that aren't visible in the repo. Boundary: `version.json` has **no
Expand Down
8 changes: 4 additions & 4 deletions .agents/skills/workflow-ci-contract/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,13 @@ description: >-

## The Core Behavioral Spine

- **PRs validate fast and never publish**: a paths-filter smoke-builds only changed targets, a type-appropriate validation job always runs, and one required aggregator gates the merge, treating skipped smoke as pass and blocking on failure or cancelled. Smoke does a full compile/lint/test but pushes nothing and uploads nothing, every `upload-artifact` gated `!smoke`.
- **PRs validate fast and never publish**: a paths-filter smoke-builds only changed targets, the caller's own job reaching the reusable validator, or the replacement it points its aggregator at, always runs, and one required aggregator gates the merge, treating skipped smoke as pass and blocking on failure or cancelled. Smoke does a full compile/lint/test but pushes nothing and uploads nothing, every `upload-artifact` gated on smoke being false, which is `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings.
- **A human merge never auto-publishes**: a `plan` job decides once and every job gates on it. Publishes come from a code-affecting bot push to `main`, a manual dispatch of `main` or `develop`, or the main-only weekly Docker schedule. Each run builds the one trigger branch, `main` a clean `X.Y.Z`, anything else a prerelease `X.Y.Z-g<sha>`, with NBGV owning the patch from git height. The release tags the built commit's SHA (`GitCommitId`), never a branch name.
- **Validate at entry**: cross-input and input-versus-derived-state invariants are asserted once in a dedicated entry job the downstream jobs `needs:`, failing fast with `::error::` before expensive work. The release gate checks branch-versus-prerelease in both directions, strips `+buildmetadata`, and on smoke skips the check while the job still succeeds.
- **The seam contract**: a target contributes a release file by uploading `release-asset-<branch>-<target>`, and the release job collects by `pattern:` plus `merge-multiple:`, never `artifact-ids:`, canonical even for a single target. A repo with no file target passes `expect_release_assets: false` at the caller.
- **Artifacts are an intra-run handoff**: consume-then-delete at the point of consumption, gated to the consumer's condition, best-effort, `retention-days: 1` on every upload as the backstop, and never a blanket delete of the run's artifact set, which destroys the diagnostics you need when the run fails.
- **No-op republish**: an unchanged version re-pushes nothing, the release-create step skips when the tag exists, registries dedupe server-side (`--skip-duplicate`, `skip-existing: true`), and Docker alone always re-pushes by design.
- **A build failure blocks every publish target**: `github-release` needs every build, the terminal registry pusher guards with `!failure() && !cancelled()`, and a package target's separate `publish-<target>` job `needs:` the release-task call, so nothing partial ships.
- **Artifacts are an intra-run handoff**: consume-then-delete at the point of consumption, gated to the half of the consumption whose failure would leave the artifact not yet redundant, best-effort, `retention-days: 1` on every upload as the backstop, and never a blanket delete of the run's artifact set, which destroys the diagnostics you need when the run fails.
- **No-op republish**: an unchanged version re-pushes nothing, the release-create step skips when the tag exists and is refreshed only on a dispatch, registries dedupe server-side (`--skip-duplicate`, `skip-existing: true`), and Docker alone always re-pushes by design.
- **A build failure blocks every publish target**: `github-release` needs every build and guards with `!failure() && !cancelled()` as the terminal registry pusher (Docker) does, and a package target's separate `publish-<target>` job `needs:` the release-task call, so no build failure ships anything partial. A failed package push is outside that, since it runs after the release is cut.

The full catalog, each guarantee with the failure mode it prevents, is in `references/d-guarantees.md`. Auditing, tracing, and probing a repo's workflows is `references/test-methodology.md`.

Expand Down
28 changes: 14 additions & 14 deletions .agents/skills/workflow-ci-contract/references/d-guarantees.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
# The D-Guarantees, Condensed

Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as input to output plus the failure mode it prevents. This is the condensed catalog for working from, and `WORKFLOW.md` keeps authority, so read the section there when a guarantee's exact wording decides a verdict.
Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as the output a conforming pipeline produces. There an item names an input only where the guarantee applies to a particular trigger or state, and names the failure it prevents only where the output does not already show it. An item naming neither still binds every repo whose shape its domain covers, and a workflow violating any applicable guarantee is not operational. This is the condensed catalog for working from, and `WORKFLOW.md` keeps authority: read the section there when a guarantee's exact wording decides a verdict, since a condensed item can be shorter than the one it condenses.

## D1: PR Fast-Feedback (Smoke)

- **D1.1** Only changed targets build: each target has a paths-filter entry, unchanged targets skip. Prevents a changed target slipping through unbuilt.
- **D1.2** A validation job always runs on any PR, and a non-.NET repo replaces it (never deletes it), re-pointing every `needs:` on it, the aggregator and `smoke-build` both. Prevents a PR merging with no validation, or a dangling `needs:` failing the workflow to load.
- **D1.3** Smoke never publishes and never uploads: full compile/lint/test, no pushes, every `upload-artifact` gated `!smoke`. Prevents a PR publishing and orphaned artifacts.
- **D1.4** Workflow-file changes are not smoke-built (the filter excludes `.github/workflows/**`), actionlint still validates them.
- **D1.5** One required aggregator gates merge: `needs:` the changes and validation jobs, passes on skipped smoke, blocks on failure or cancelled, and its name is ruleset-bound (job `name:` equals ruleset `context:`, renamed together).
- **D1.6** Coverage reports to Codecov for C# and Python repos with tests, best-effort so an outage never reds the gate, with a `codecov.yml` setting statuses informational and `.gitignore` excluding coverage output.
- **D1.1** Only changed targets build: each target has a paths-filter entry naming the paths it is built from, unchanged targets skip, and a change touching no target's paths marks nothing. A filter written as a negation of what must not build marks a docs-only change as a target change and fails this item. Prevents a changed target slipping through unbuilt.
- **D1.2** A validation job always runs on any PR: the caller's own job reaching the reusable validator, named `validate` in every shipped stub, which is the name the aggregator `needs:`. The validator's internal jobs are not addressable from a caller, and one of the hub's is itself called `validate`, so the matching name in a `needs:` list is always the caller's own job. It detects the tree rather than the language, so a non-.NET repo calls the same validator. A repo whose validation it cannot express replaces the call (never deletes it) and re-points the aggregator's `needs:`. `smoke-build` `needs:` the `changes` job, not the validation job. Prevents a PR merging with no validation, or a dangling `needs:` that stops the whole workflow from loading.
- **D1.3** Smoke never publishes and never uploads: full compile/lint/test, no pushes, every `upload-artifact` gated on smoke being false, `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings. Prevents a PR publishing and orphaned artifacts.
- **D1.4** A PR changing only `.github/workflows/**` is not smoke-built, since an inclusion list satisfying D1.1 matches no workflow path, and actionlint still validates them.
- **D1.5** One required aggregator gates merge: `if: always()`, `needs:` the validation job plus the `changes` and `smoke-build` jobs wherever the repo has a smoke build, passes on skipped smoke, blocks on failure or cancelled, and its name is ruleset-bound (job `name:` equals ruleset `context:`, renamed together).
- **D1.6** Coverage reports to Codecov for C# and Python repos with tests, a lint-only profile for that type excepted, best-effort so an outage never reds the gate, with a `codecov.yml` setting statuses informational and `.gitignore` excluding coverage output.

## D2: Validation at Entry

Expand All @@ -29,16 +29,16 @@ Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as input to output
## D4: Release and Publish

- **D4.1** Gated single-branch publish: a human merge never auto-publishes, the `plan` job decides once, publishes come from a code-affecting bot push to `main`, a dispatch of `main`/`develop`, or the main-only weekly Docker schedule.
- **D4.2** `target_commitish` is the built commit's SHA (NBGV `GitCommitId`), never a branch name and never `github.sha`.
- **D4.3** Every release is a tag plus source zip, README, and LICENSE, file targets attach `release-asset-*`, and a no-file-target caller passes `expect_release_assets: false` or the release-create step fails on unmatched files.
- **D4.4** No-op republish: an unchanged version re-pushes nothing, the release-create skips when the tag exists (refreshed only on `workflow_dispatch`), registries dedupe server-side, and Docker always re-pushes by design.
- **D4.5** A failed build blocks every publish target: `github-release` needs every build, the terminal registry pusher guards `!failure() && !cancelled()`, and a package target's separate `publish-<target>` job `needs:` the release-task call, so nothing partial ships.
- **D4.2** `target_commitish` is the built commit's SHA (NBGV `GitCommitId`), never a branch name and never a separately re-resolved ref.
- **D4.3** Every release is a tag plus source zip, README, and LICENSE, `prerelease` equals `branch != default`, file targets attach `release-asset-*`, and a no-file-target caller (Docker-only, PyPI-only, source-only) passes `expect_release_assets: false` or the release-create step fails on unmatched files, a source-only one setting every `enable_*` input false with it. A NuGet caller is not one of those, since its leaf uploads a `release-asset-*` carrying the package.
- **D4.4** No-op republish on a schedule or push trigger: an unchanged version re-pushes nothing and the release-create skips when the tag exists, while a dispatch re-run refreshes it and runs the paired asset delete with it, registries dedupe server-side under `dotnet nuget push --skip-duplicate` and PyPI's `skip-existing: true`, and Docker always re-pushes by design.
- **D4.5** A failed build blocks every publish target: `github-release` needs every build and the terminal registry pusher (Docker) needs every other build, both guarding `!failure() && !cancelled()` so a disabled or unchanged target, skipped rather than failed, still lets the release be cut and the image pushed, and a package target's separate `publish-<target>` job `needs:` the release-task call, so no build failure ships anything partial. A failed **package** push is outside that: the `publish-<target>` job runs after the whole release task and so after `github-release`, and can leave a release and tag for a version the registry never received, recovered by re-dispatching the same commit rather than by cleanup.
- **D4.6** A deploy check asserts which release and which environment answer, waiting for convergence to a bounded timeout, with an unreachable host reported distinctly from an HTTP status.

## D5: Resource Cleanup

- **D5.1** A cross-job transfer artifact is deleted at its point of consumption. An in-run intermediate may rely on the retention backstop.
- **D5.2** The delete runs under the same condition as its consumer, so a no-op re-run skips the release-asset delete while the `nuget-build-*` and `pypi-build-*` deletes still run.
- **D5.1** A cross-job transfer artifact is deleted by exact name or pattern at its point of consumption. An in-run intermediate may rely on the retention backstop.
- **D5.2** The delete runs exactly when the consumption happened: the same condition as a conditional consumer (the release create), and `if: ${{ !cancelled() && steps.<download-step-id>.outcome == 'success' }}` where the consumer is a push that always attempts, since a delete with no status-check function in its `if:` inherits `success()` and would skip on the failed push. So a no-op re-run that is not a dispatch skips the release-asset delete while the `nuget-build-*` and `pypi-build-*` deletes still run, and a dispatch re-run refreshes the release and runs the asset delete with it.
- **D5.3** Cleanup is best-effort (`continue-on-error`, tolerate a failed listing, delete all matching ids).
- **D5.4** Every `upload-artifact` sets `retention-days: 1`.
- **D5.5** Never blanket-delete the run's artifacts, which destroys diagnostics and auto-emitted build records.
Expand All @@ -56,7 +56,7 @@ Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as input to output
- **D7.1** The publisher serializes: global ref-independent concurrency group, `cancel-in-progress: false`.
- **D7.2** A reusable job declares `permissions:` only where every caller grants that scope at startup (the block is validated before `if:`), and otherwise declares none and runs under the calling job's grant, a callee's extra scope granted by the caller at the one entry point needing it.
- **D7.3** Boolean inputs are declared in both trigger blocks and compared against both forms.
- **D7.4** Optional-dependency chaining allowlists `success`/`skipped` explicitly.
- **D7.4** Optional-dependency chaining allowlists `success`/`skipped` explicitly, beside a status-check function, since the implicit `success()` is false the moment any `needs:` job skipped.

## D8: Bots and Automation

Expand Down
Loading