From 752e656259d3cce746ba671c0a81603ddc3a4b0a Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 26 Jun 2026 21:26:00 -0700 Subject: [PATCH 1/5] Add WORKFLOW.md: workflow style + architecture + contract + test methodology A standalone guide for CI/CD workflows, sibling to CODESTYLE.md. Its defining principle is contract-not-implementation: it states required outcomes (inputs -> outputs) and a test methodology, so an agent can audit, test, and assess whether any project's workflows are operational - without dictating how they are built. Mined from the full aggregate of the recent template/porting work (~40 workflow issues/PRs), not just the latest defects. Behaviors are organized into nine domains (D1 fast-feedback, D2 input/state validation, D3 versioning, D4 release, D5 resource cleanup, D6 seam, D7 concurrency/permissions, D8 bots, D9 style), each guarantee stated as the failure-mode it prevents (portable, no issue numbers). Section 5 gives a static-audit checklist, ten end-to-end trace scenarios, and a live-probe procedure; section 6 walks each project type. Scope: new file only; the AGENTS.md "Workflow YAML Conventions"/"Release Model" reconciliation is deferred until this doc is ratified (noted inline). Verification: markdownlint clean; CRLF per .editorconfig; the section 5A checklist run against the template's own workflows reports 20/20 OPERATIONAL, calibrating the methodology. Co-Authored-By: Claude Opus 4.8 (1M context) --- WORKFLOW.md | 431 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 431 insertions(+) create mode 100644 WORKFLOW.md diff --git a/WORKFLOW.md b/WORKFLOW.md new file mode 100644 index 00000000..6b072e9c --- /dev/null +++ b/WORKFLOW.md @@ -0,0 +1,431 @@ +# WORKFLOW.md + +The single guide for CI/CD **workflows** (GitHub Actions). It is a deliberate mixture of code +style, architecture, a **behavioral contract** (expected inputs and outputs), and a **test +methodology**. Code style lives in [`CODESTYLE.md`](./CODESTYLE.md); this file is its sibling for +everything under [`.github/workflows/`](./.github/workflows/). + +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 the template byte for byte. The conventions in section 2 are how +we keep workflows legible and reviewable; the contract in section 4 is what they must *do*. + +Given this document, an agent must be able to do three things to any project: + +1. **Audit** - statically check the workflows against the conventions (section 2) and the + structural facts each guarantee implies (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 and every + scenario's observed output equals the expected) or **not operational** (any mismatch - which is + a *defect*, not a style nit). + +> **Reconciliation pending.** Some conventions in section 2 currently also appear in `AGENTS.md` +> ("Workflow YAML Conventions") and the release behavior in `AGENTS.md` "Release Model". This is +> intentional short-term duplication while this document is ratified. Once ratified, the canonical +> copy moves here and `AGENTS.md` points to it (the `CODESTYLE.md` pattern); treat any difference +> as "this file is being adopted," not drift. + +The guarantees below are distilled from real failures observed in practice. They are stated as the +**failure-mode each prevents**, so the document stays portable to any project. + +## 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; the input/output behavior may not. +- **Operational is binary.** A workflow is operational only if every applicable guarantee holds. + A single input/output mismatch is a defect and makes the workflow non-operational, regardless of + how clean the YAML looks. +- **The three verbs.** Audit (static), Test (trace + probe), Assess (verdict). Section 5 gives an + agent the exact procedure. +- **Provenance.** Each guarantee encodes a failure that has actually shipped. The failure-mode is + named so the *why* survives even when the implementation changes. + +## 2. Workflow style conventions + +These are prescriptive style/legibility rules. They are cheap to check and keep workflows +reviewable; they are necessary but not sufficient (a perfectly styled workflow can still violate +the section 4 contract). + +- **Action pinning.** Pin **every** action - first-party (`actions/*`) and third-party - to a + commit SHA with a trailing `# vX.Y.Z` comment, so a tag swap cannot change executed code while + Renovate/Dependabot can still bump it. Use `# vX` (major-only) only when the upstream floating + major tag has no specific patch SHA. The single documented no-pin exception is a tool whose tag + stream lags `master` such that tag-tracking would propose a downgrade (in this template, + `dotnet/nbgv@master`); do not invent new exceptions, including for repo-owned build-layer leaves. +- **Filename.** Reusable workflows (`on: workflow_call`) end in `-task.yml`. Entry-point workflows + (`on: push` / `pull_request` / `schedule` / `workflow_dispatch`) do **not** use `-task`; they end + with what they do (`-pull-request.yml`, `-release.yml`). The suffix is semantic: a `-task.yml` is + meant to be `uses:`-d, never triggered directly. Names are lowercase, hyphen-separated. +- **Workflow `name:`.** Reusable workflow names end in **"task"** (`Build PyPI library task`); + entry-point names end in **"action"** (`Publish project release action`). The UI label then tells + you at a glance whether you are looking at an orchestrator or a callee. +- **Job and step `name:`.** Every job `name:` ends in **"job"**; every step `name:` ends in + **"step"**. **Exception:** a job whose `name:` is bound as a required-status-check `context:` in a + branch ruleset keeps that exact name verbatim - renaming silently breaks required-check + enforcement. +- **Concurrency.** Top-level workflows declare + `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }`. + Document any exception inline (see D7 for the publisher's global, no-cancel group and the + merge-bot's ordered, no-cancel group). +- **Shells.** Every multi-line bash `run:` starts with `set -euo pipefail`. +- **Conditionals.** Multi-line `if:` uses the folded scalar `if: >-` (a literal block `if: |` + embeds newlines into the boolean expression and is wrong). +- **Boolean inputs.** A boolean input used by both `workflow_call` and `workflow_dispatch` is + declared in **both** trigger blocks. `workflow_call` delivers a real boolean; `workflow_dispatch` + delivers the **string** `"true"`/`"false"`. Any `if:` consuming one must compare both forms: + `if: ${{ inputs.foo == true || inputs.foo == 'true' }}`. +- **Reusable-workflow permissions.** Job-level `permissions:` are validated **before** `if:` + evaluates, so even a skipped job needs valid permissions declared. Grant least privilege; for a + reusable callee that needs a scope (e.g. `actions: write` to delete artifacts), the **caller** + grants it at the `uses:` job. +- **Allowlist `success` and `skipped` explicitly** when chaining across optional dependencies. + `!= 'failure'` lets `cancelled` through; use `(needs.X.result == 'success' || needs.X.result == 'skipped')`. +- **Docker layer cache.** Cache to/from a registry tag (`type=registry`, e.g. `buildcache-`), + never the Actions cache (`type=gha`). Multi-image repos use a per-image buildcache tag. +- **Line endings.** Workflow YAML follows `.editorconfig` (CRLF in this template). Committed JSON + state files follow the repo's JSON line-ending rule. Preserve endings on every edit. + +## 3. Architecture + +### Two layers: orchestration vs build + +The pipeline splits into a generic **orchestration layer** and a repo-owned **build layer**. + +- **Orchestration** (generic, intended to be synced verbatim): the publish plan and branch matrix, + the version step, the release-tagging/asset-attaching job, the date-badge job, and the + aggregator shape of the PR workflow. It is target-agnostic and should not need per-repo edits. +- **Build** (repo-owned): the `build--task.yml` leaf tasks. A derived project owns and + replaces these; it curates only the *list* of leaf jobs the orchestrator calls. + +### The seam contract + +A target contributes a file to the GitHub release by uploading a workflow artifact named +`release-asset--`. The release job collects **every** matching artifact by +**pattern** (`pattern: release-asset--*` + `merge-multiple: true`), never by an +`artifact-ids:` that names a specific build job's output. This is canonical for **every** repo, +single-target included: name your one asset to the pattern and the verbatim release job globs it. +Switching a single-target repo to an `artifact-id` output forks the release download and breaks the +verbatim carry. + +### Reusable-task parameter contract + +Every `build-*-task.yml` and the release task take: `ref` (git ref to check out/version), `branch` +(the **logical** branch that drives config/tags/prerelease), and where relevant `smoke`. +Branch-derived config keys off `inputs.branch`, **never** `github.ref_name` - the publisher's +matrix builds the non-default branch from a run whose `github.ref_name` is the default branch, so +`ref_name` would be wrong. Artifact names are branch-suffixed so both matrix legs coexist in one +run. + +### Versioning + +NBGV computes the version. It MUST version from the **checked-out branch**, not the runner's CI ref +(set `IGNORE_GITHUB_REF=true`; `GITHUB_REF` is reserved and a step `env:` cannot override it). The +default branch is the public-release ref (`publicReleaseRefSpec`), so it builds a clean public +`X.Y.Z`; every other branch builds a prerelease `X.Y.Z-g`. `version.json`'s `version` is the +major.minor floor; NBGV appends the git height as the patch. A package build derives its registry +version from the same source (PyPI uses a PEP 440 `.devN` suffix off the default branch). A repo +that wraps an upstream release may drive its build/image version from an external committed +`name -> version` state file while NBGV still tags the GitHub release. + +### Validate-at-entry + +When a workflow's inputs must satisfy a cross-input or input-versus-derived-state invariant, assert +it **once** in a dedicated entry job/step that the downstream jobs `needs:`, failing fast before any +build or publish. One gate with a clear `::error::` beats partial checks scattered through later +jobs. + +### Resource lifecycle + +Workflow artifacts are an **intra-run handoff** only; durable copies live on the release/registry. +Each transfer artifact is deleted by exact name/pattern **at its point of consumption**, the delete +is **gated to the same condition as the consumer**, it is **best-effort**, and **every** upload sets +`retention-days: 1` as the failure-path backstop. The run is **never** blanket-deleted. See D5. + +### Fast PR feedback + +PRs validate fast and never publish. A paths-filter detects changed targets and smoke-builds only +those; unit tests always run; smoke builds compile/lint/test but upload nothing and push nothing. +A single required aggregator gates the merge. See D1. + +### Release model + +Two-phase by default: PRs smoke-test, merges do not publish. The publisher (weekly schedule + +manual dispatch) builds and publishes **both** branches via a matrix; its `push` trigger publishes +only when an opt-in repository variable is set. Every release is a tag on the built commit plus a +source zip, README, and LICENSE; targets amend it with `release-asset-*` files or push to their own +registry. An unchanged version re-pushes nothing (no-op republish); Docker re-pushes by design to +pick up base-image refreshes. See D3/D4. + +### Output seam by destination + +Pick each output's path by **where the artifact goes**, not by language: + +- **File on the GitHub release** (zip, binary, packaged library): one leaf task per output, each + uploading `release-asset--`. +- **Package-registry push** (NuGet, PyPI): the leaf builds and the package is published to its + registry. NuGet pushes from the leaf *and* uploads a `release-asset-*`; PyPI is split - the leaf + only builds + uploads its build artifact, and a separate publish job does the OIDC upload (so + `id-token: write` is granted at one entry point) and contributes **no** `release-asset-*`. +- **Image-registry push** (Docker): the leaf pushes multi-arch tags and contributes no + `release-asset-*`. +- **Source-only** (validate + tag): no package/image leaf - only validation, optionally one + `release-asset-*`, and the verbatim release orchestration. + +## 4. Behavioral contract - expected outcomes + +The required behaviors, organized by domain. Each is a **MUST**, stated as input -> output plus the +failure-mode it prevents. A workflow that violates any applicable guarantee is **not operational**. + +### D1 - PR fast-feedback (smoke) + +- **D1.1 Only changed targets build.** Input: a PR touching some targets. Output: the paths-filter + marks exactly those targets, and only their smoke builds run; unchanged targets skip. *Prevents: + rebuilding everything on every PR, and the inverse - a changed target slipping through unbuilt.* +- **D1.2 Unit tests always run.** Input: any PR. Output: the unit-test job runs unconditionally. +- **D1.3 Smoke never publishes and never uploads.** Input: a smoke build (`smoke: true`). Output: + full compile/lint/test, but **no** registry/image push, **no** release, and **no** artifact + uploads (every `upload-artifact` is gated `!smoke`). *Prevents: a PR publishing, and orphaned + artifacts churning the storage quota.* +- **D1.4 Workflow-file changes are not smoke-built.** Input: a PR changing only + `.github/workflows/**`. Output: the paths-filter excludes workflow files, so smoke-build skips + (a filter cannot tell a logic change from a version-bump). *Implication: there is no CI + workflow-lint; workflow edits MUST be linted locally (actionlint).* +- **D1.5 One required aggregator gates merge.** Input: any PR. Output: a single aggregator job + must **succeed** (not merely "not fail"), `needs:` the changes job, treat a **skipped** smoke + build as pass, and **block** on any `failure`/`cancelled`. Its name is ruleset-bound and MUST NOT + be renamed. *Prevents: a paths-filter error letting a target-changing PR merge with its smoke + build silently skipped.* + +### D2 - Input/state validation at entry + +- **D2.1 Validate before expensive work.** Input: a workflow whose inputs carry a cross-input or + input-versus-derived-state invariant. Output: a dedicated entry job/step asserts it and fails + fast with `::error::` before builds; downstream jobs `needs:` it. *Prevents: cryptic failures + deep in matrix expansion.* +- **D2.2 Release branch matches version classification.** Input: a real (non-smoke) release build. + Output: the gate fails loudly if the default branch carries a prerelease suffix, **or** a + non-default branch carries none. It strips `+buildmetadata` before testing for the prerelease `-` + (only a `-` in the core/prerelease segment counts), and it is **skipped on smoke** (a smoke build + checks out a detached PR head and always versions as prerelease). *Prevents: a non-default leg + published as stable; a build-metadata hyphen false-positive; the gate blocking every + default-base promotion PR.* +- **D2.3 Publish only from the default branch.** Input: a `workflow_dispatch` (or schedule) publish. + Output: a dispatch from a non-default ref fails fast. *Prevents: the matrix building the other + branch leg from the wrong ref and shipping a malformed non-prerelease "Latest".* +- **D2.4 Mutually-exclusive / paired inputs are validated.** Input: a workflow with either/or or + must-pair inputs (e.g. a repo-list vs a manifest+jq pair). Output: a half-filled or conflicting + combination fails fast. *Prevents: a silent fall-through from a partially specified input.* + +### D3 - Versioning and classification + +- **D3.1 Version from the checked-out branch.** Input: a matrix publish dispatched from the default + branch, each leg checking out its own branch. Output: each leg's version reflects **its** branch + (`IGNORE_GITHUB_REF=true`), so the non-default leg stays a prerelease. *Prevents: every leg being + classified as the public ref because the CI ref is the default branch.* +- **D3.2 Default = public, others = prerelease.** Output: default branch -> `X.Y.Z` + (`PublicRelease=true`); any other branch -> `X.Y.Z-g` (`PublicRelease=false`). +- **D3.3 Version floor + git height.** Output: `version.json` sets the major.minor floor; NBGV + appends the git height as the patch. The floor is bumped only for a functional change, by the + maintainer, in the PR that introduces it - never on a cadence. (No `pathFilters`, so any commit + advances the height.) +- **D3.4 Registry versions follow the same classification.** Output: NuGet default = stable, others + = prerelease; PyPI default = release, others = a PEP 440 `.devN` build. +- **D3.5 Wrapper repos may use an external version.** Output: a repo wrapping an upstream release + drives its build/image version from a committed `name -> version` state file, while NBGV's version + still tags the GitHub release. *Prevents: a wrapper being forced onto its own NBGV version for the + immutable image tag.* + +### D4 - Release / publish + +- **D4.1 Two-phase by default.** Output: PRs smoke-test; merges do **not** publish unless the opt-in + variable is set; the publisher's schedule and dispatch always publish both branches. +- **D4.2 Tag the built commit.** Output: the release's `target_commitish` is the **built commit's + SHA** (NBGV's commit id), never `github.sha` (wrong branch in the publisher matrix) or a moving + branch ref. *Prevents: the release tag landing on the default branch instead of the built tree.* +- **D4.3 Release contents.** Output: every release is a tag on the built commit plus the auto + source zip, README, and LICENSE; file-producing targets attach `release-asset-*`. The prerelease + flag equals `branch != default`. +- **D4.4 No-op republish.** Input: a re-run whose version is unchanged. Output: nothing is + re-pushed - the release-create step is skipped when the tag already exists (and refreshed only on + `workflow_dispatch`); registry pushes use skip-duplicate/skip-existing. **Docker always + re-pushes** by design (base-image refresh). *Prevents: duplicate releases and wasted pushes.* + +### D5 - Resource cleanup + +- **D5.1 Delete at the point of consumption.** Output: the job that downloads a transfer artifact + deletes it (by exact name/pattern) right after consuming it - including per-runtime intermediates + consumed by an aggregation job. *Prevents: transfer artifacts accumulating against the storage + quota.* +- **D5.2 Gate the delete to the consumer's condition.** Output: the delete runs under the **same** + condition as the consuming step (e.g. only when a release was actually created/refreshed). *Prevents: + deleting freshly built artifacts on a no-op re-run where nothing consumed them.* +- **D5.3 Best-effort.** Output: cleanup is `continue-on-error`, tolerates a failed artifact + listing, and deletes **all** matching ids. *Prevents: a cleanup hiccup reddening a job whose + publish already succeeded.* +- **D5.4 Retention backstop.** Output: **every** `upload-artifact` sets `retention-days: 1`, so a + job that dies before its consumer leaves nothing beyond a day - no separate terminal cleanup job + is needed. +- **D5.5 Never blanket-delete.** Output: cleanup MUST NOT enumerate and delete the run's whole + artifact set (`.artifacts[].id`). *Prevents: destroying diagnostic/log artifacts and the + build-records actions emit automatically - exactly what you need to debug a failed run.* + +### D6 - Seam / architecture conformance + +- **D6.1 Pattern handoff.** Output: the release job downloads assets by `pattern:`/`merge-multiple:`, + not `artifact-ids:`; targets upload `release-asset--`. Canonical for single-target + repos too. *Prevents: a single-target repo forking the verbatim release download.* +- **D6.2 Branch drives config.** Output: branch-derived config reads `inputs.branch`, never + `github.ref_name`. *Prevents: the publisher matrix mislabeling the non-default leg.* +- **D6.3 Branch-suffixed artifacts.** Output: artifact names are branch-suffixed so both matrix legs + coexist in one run. +- **D6.4 Per-target subsetting is clean.** Output: dropping a target removes its leaf task, its job + and `needs` entry in the release task, its path-filter entry, and (for the split PyPI publish) its + publish job - and leaves the release orchestration untouched. + +### D7 - Concurrency, permissions, safety + +- **D7.1 Publisher serializes.** Output: the publisher uses a **global, ref-independent** concurrency + group with `cancel-in-progress: false`, so a scheduled run and a manual dispatch cannot run + concurrently and double-push, and a mid-flight publish is never cancelled into a partial release. +- **D7.2 Skipped jobs still need valid permissions.** Output: every reusable job declares valid + `permissions:` (validated before `if:`); a callee's extra scope (e.g. `actions: write` for + cleanup) is granted by the caller. *Prevents: `startup_failure` on a skipped job, or a cleanup + step lacking permission.* +- **D7.3 Boolean inputs both forms.** Output: boolean inputs are declared in both trigger blocks and + compared against `true` and `'true'`. +- **D7.4 Optional-dependency chaining.** Output: cross-job conditions allowlist `success`/`skipped` + explicitly rather than `!= 'failure'`. + +### D8 - Bots / automation + +- **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened`; dispatches `--squash` or + `--merge` by the PR's base ref; disables auto-merge on a maintainer-pushed `synchronize`; and its + concurrency is 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 (re-runnable, convergent); Dependabot targets both branches, + with security PRs against 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 keys that + moved, the merge-bot auto-merges it, and the bump ships on the **next** publish. + +### D9 - Style / static (see section 2) + +- **D9.1** Every action is SHA-pinned with a version comment (sole exception: the documented + lagging-tag tool). +- **D9.2** File, workflow, job, and step names follow the suffix rules; ruleset-bound names are + verbatim. +- **D9.3** Bash `run:` blocks start `set -euo pipefail`; multi-line `if:` uses `>-`. +- **D9.4** Docker layer cache targets a registry tag, not `type=gha`. +- **D9.5** Line endings follow `.editorconfig`. + +## 5. Test methodology + +An agent verifies a project against this contract in three escalating modes, then renders a verdict. + +### 5A. Static audit (no execution) + +Read the workflow files plus `version.json` and assert the observable structural fact behind each +D-guarantee. Each item is pass/fail with a `file:line` citation. The core checklist: + +- **D1:** a paths-filter `changes` job exists and excludes `.github/workflows/**`; smoke calls the + build task with `smoke: true`, `github: false` (and no registry/image push); every + `upload-artifact` in the build tasks is gated `!smoke`; the required aggregator `needs:` `changes` + and fails on `failure`/`cancelled` while passing on `skipped`. +- **D2:** an entry validation job/step exists for each complex-input workflow; the release gate + checks both directions, strips `+buildmetadata`, and skips on smoke; the publisher rejects a + non-default-ref dispatch; either/or inputs are validated. +- **D3:** the version step sets `IGNORE_GITHUB_REF=true`; `version.json` has `publicReleaseRefSpec` + on the default branch only; PyPI applies a `.devN` off-default. +- **D4:** `target_commitish` is the built commit id; `prerelease` equals `branch != default`; the + release-create step is gated on `exists == false || workflow_dispatch`; registry pushes use + skip-duplicate/skip-existing. +- **D5:** each transfer artifact has a delete step at its consumer, **gated to the consumer's + condition**, `continue-on-error: true`, looping all ids; **every** upload sets + `retention-days: 1`; **no** `.artifacts[].id` blanket delete exists anywhere. +- **D6:** the release download uses `pattern:`/`merge-multiple:` (no `artifact-ids:`); config reads + `inputs.branch` (grep for `github.ref_name` in branch-derived config is a finding); artifact + names are branch-suffixed. +- **D7:** the publisher concurrency group is ref-independent with `cancel-in-progress: false`; + reusable jobs declare permissions; boolean `if:` uses both forms. +- **D8/D9:** merge-bot concurrency keys on PR number; actions are SHA-pinned; names/suffixes and + shells/conditionals follow section 2. + +### 5B. End-to-end trace scenarios (no execution, deterministic from the YAML) + +For each 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. These +are deterministic from the workflow text - the cheapest way to catch a behavioral defect. Minimum +set (each lists the domains it exercises): + +| # | Input | Expected output | Exercises | +| --- | --- | --- | --- | +| S1 | PR touching a build target (default-base) | `changes` flags it; `unit-test` runs; that target's smoke build runs (`smoke:true`); no push, **no uploads**; validate-release **skipped (smoke), succeeds**; release job **skipped**; aggregator **success**; version = prerelease; no release; no dangling artifacts | D1, D2.2, D3 | +| S2 | PR changing only docs | smoke-build **skipped**; `unit-test` runs; aggregator **success**; no build/release | D1.1, D1.5 | +| S3 | PR changing only `.github/workflows/**` | filter excludes -> smoke-build **skipped**; aggregator **success** (lint locally) | D1.4 | +| S4 | PR base = default branch, carrying a build target (promotion) | smoke versions as prerelease (detached head); validate-release **skipped (smoke)** so the default-branch arm does **not** fire; aggregator **success**; promotion not blocked | D1.3, D2.2 | +| S5 | push to non-default branch, opt-in variable unset | `setup` -> publish=false; nothing publishes | D4.1 | +| S6 | push to non-default branch, opt-in variable set | publish=true for that branch; it publishes a **prerelease** | D3, D4 | +| S7 | scheduled/dispatched publish from default branch | matrix builds both legs: non-default leg -> `X.Y.Z-g`, release `prerelease=true`, registry prerelease, assets consumed-then-deleted; default leg -> `X.Y.Z`, release `prerelease=false`, registry stable, badge/readme run; **no dangling artifacts** | D3, D4, D5, D6, D7 | +| S8 | `workflow_dispatch` from a non-default ref | `setup` **fails fast** with the guard error | D2.3 | +| S9 | re-run publish, version unchanged (tag exists) | schedule -> release-create **skipped**, paired delete **skipped**, registry pushes no-op; dispatch -> refresh; Docker re-pushes; no duplicate release | D4.4, D5.2 | +| S10 | a build whose branch and version classification disagree | validate-release **fails loud**; build/publish skip; nothing bad ships | D2.2 | + +### 5C. Live probe (where warranted) + +- Open a trivial-change PR touching one target and confirm S1 in the run. +- Drive a `smoke: true` push-probe of the build task for **both** `branch: ` and + `branch: ` and assert the version classification (clean vs prerelease) and that the + gate passes - **without publishing**. This proves D3.1/D2.2 end to end at near-zero risk. +- Inspect the latest real publish's logs for `PublicRelease`/`SemVer2` per leg and confirm the + artifact lifecycle (uploaded, consumed, deleted; none left behind). + +### Assessment + +The workflow is **operational** iff every applicable 5A item passes **and** every 5B scenario's +observed output equals the expected (confirmed by 5C where a live signal is available). Any mismatch +is a **defect** -> **not operational**. Recommended agent procedure: + +1. **Audit** with 5A; record each pass/fail with `file:line`. +2. **Trace** S1-S10 with 5B; diff predicted vs expected tables. +3. **Probe** with 5C only for guarantees a static trace cannot fully settle (live version + classification, real artifact lifecycle). +4. **Verdict:** operational / not operational, with the failing guarantee(s) and the input that + triggers each. + +## 6. Per-project-type test walkthroughs + +The template covers several project shapes. Each maps the same S1-S10 onto its targets; the +differences are which leaf tasks exist and what each produces. Walking these is the self-check that +the contract holds for every shape. + +- **Console / executable application.** Target produces `release-asset--executable` (a + zip of `dotnet publish` output) from a per-runtime matrix; the per-runtime intermediates + (`publish--`) are themselves transfer artifacts and MUST be consume-then-deleted + by the aggregation job (D5.1). Test: S1 with a console change smoke-builds a reduced runtime + subset and uploads nothing; S7 attaches the executable zip, prerelease on the non-default leg and + Latest on the default leg. +- **NuGet library.** Target both pushes (`dotnet nuget push --skip-duplicate`) and uploads + `release-asset--nugetlibrary`. Test: S7 non-default leg publishes a **prerelease** package + (`X.Y.Z-g`, `isPrerelease=true`) plus the asset; default leg publishes a stable `X.Y.Z`; S9 + re-run is a `--skip-duplicate` no-op. +- **PyPI library.** Leaf builds + uploads `pypilibrary-build-`; a **separate** publish job + does the OIDC Trusted-Publishing upload (`id-token: write`, an environment gate) and then + **consume-then-deletes** the build artifact; PyPI contributes no `release-asset-*`. Test: S7 + default leg publishes a release version, non-default a PEP 440 `.devN`; S9 re-run is a + `skip-existing` no-op; confirm the build artifact is deleted after publish (D5.1). +- **Docker image.** Leaf pushes multi-arch tags with a registry buildcache; no `release-asset-*` + (so `expect_release_assets` is false - the release is tag + source/README/LICENSE only); the + Docker-Hub readme and date-badge run only when the default branch publishes. Docker **always** + re-pushes (D4.4). A wrapper repo drives the immutable tag from an external version (D3.5). Test: + S7 default leg pushes `latest` + the version tag and updates the readme/badge; non-default pushes + the develop tag; S9 still re-pushes the image. +- **Data / asset library.** A single leaf: validate -> zip -> upload + `release-asset--library`; it drops the nuget/pypi/executable/docker jobs and the PyPI + publish job and keeps the release orchestration verbatim. Test: S7 attaches the zip, prerelease on + the non-default leg. +- **Source-only / no build.** No package/image leaf; validation lives in the PR workflow; the + release is a tag + source zip + README + LICENSE (zero or one `release-asset-*`). Test: S1 runs + validation only; S7 cuts a release with no build asset. From 95f749cc570f26b68a5a2707396cf95abed2d3b9 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 26 Jun 2026 21:44:25 -0700 Subject: [PATCH 2/5] WORKFLOW.md v2: incorporate round-1 per-type assessor feedback Six per-type domain assessors (console, nuget, pypi, docker, data/asset, source-only) reviewed v1 for usability. Cross-cutting fixes: - Define "applicable" / N-A and exclude N-A items from the verdict (near-empty pipelines like source-only are mostly N-A, not failing). - Disambiguate the orchestrator vs build-leaf layers; assert each input in the file that declares it (github/nuget/dockerhub/expect_release_assets are release-task inputs, not leaf inputs). - A no-file-target repo (docker/pypi/source-only) reaches the tag-only shape only by its caller passing expect_release_assets:false; reconcile with the "verbatim orchestration" claim. - D1.2: a type-appropriate validation job (not specifically the .NET unit-test) runs unconditionally; non-.NET repos replace it and keep the aggregator wired. - NBGV/version.json are retained even by no-compiler repos (they own the tag). - Adding/dropping a target edits the orchestrator surface (enable input + job + needs + paths-filter); "verbatim" applies to the github-release job body. Per-type fixes: PyPI version is .dev0 from AssemblyFileVersion on develop only (not generic .devN off SemVer2) + ordering property; PyPI build-artifact delete is unconditional (diverges from release-asset gating on no-op, S9); per-runtime executable intermediates rely on the retention backstop (reconciled with D5.1); D3.5 wrapper external-version is a tracker-only skeleton (leaf must wire the read); NuGet snupkg triple-surface + --skip-duplicate server-dedupe semantics; Docker expect_release_assets/buildcache/secret-on-smoke/readme-validation; new S11 wrapper-bump scenario; per-registry 5C probes; 7z-not-zip; default-branch literal agreement check. Verification: markdownlint clean; CRLF; no issue numbers; the 5A static audit re-run against the template's own workflows still reports 20/20 OPERATIONAL. Co-Authored-By: Claude Opus 4.8 (1M context) --- WORKFLOW.md | 652 +++++++++++++++++++++++++++++----------------------- 1 file changed, 370 insertions(+), 282 deletions(-) diff --git a/WORKFLOW.md b/WORKFLOW.md index 6b072e9c..e28f9cdb 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -8,8 +8,8 @@ everything under [`.github/workflows/`](./.github/workflows/). 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 the template byte for byte. The conventions in section 2 are how -we keep workflows legible and reviewable; the contract in section 4 is what they must *do*. +outputs** - not when it matches the template byte for byte. The conventions in section 2 keep +workflows legible; the contract in section 4 is what they must *do*. Given this document, an agent must be able to do three things to any project: @@ -17,9 +17,9 @@ Given this document, an agent must be able to do three things to any project: structural facts each guarantee implies (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 and every - scenario's observed output equals the expected) or **not operational** (any mismatch - which is - a *defect*, not a style nit). +3. **Assess** - render a verdict: **operational** (every *applicable* guarantee holds and every + *applicable* scenario's observed output equals the expected) or **not operational** (any + mismatch - which is a *defect*, not a style nit). > **Reconciliation pending.** Some conventions in section 2 currently also appear in `AGENTS.md` > ("Workflow YAML Conventions") and the release behavior in `AGENTS.md` "Release Model". This is @@ -27,405 +27,493 @@ Given this document, an agent must be able to do three things to any project: > copy moves here and `AGENTS.md` points to it (the `CODESTYLE.md` pattern); treat any difference > as "this file is being adopted," not drift. -The guarantees below are distilled from real failures observed in practice. They are stated as the -**failure-mode each prevents**, so the document stays portable to any project. +The guarantees are distilled from failures observed in practice and stated as the **failure-mode +each prevents**, so the document stays portable to any project. ## 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; the input/output behavior may not. -- **Operational is binary.** A workflow is operational only if every applicable guarantee holds. - A single input/output mismatch is a defect and makes the workflow non-operational, regardless of - how clean the YAML looks. -- **The three verbs.** Audit (static), Test (trace + probe), Assess (verdict). Section 5 gives an - agent the exact procedure. -- **Provenance.** Each guarantee encodes a failure that has actually shipped. The failure-mode is - named so the *why* survives even when the implementation changes. +- **Applicability.** A guarantee (or a 5A check, 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. +- **Default branch.** Guarantees say "default branch" portably; the template implements it 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 (section 5A). +- **Two layers when auditing.** The pipeline splits into an **orchestrator** layer (the PR + entry workflow, the publisher, and the version/release/badge jobs) and a **build-leaf** layer + (`build--task.yml`). Inputs like `github`/`nuget`/`dockerhub`/`expect_release_assets` + live on the orchestrator; a leaf only ever receives `ref`/`branch`/`smoke` (and a derived + `push`). When a check names an input, assert it 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 -These are prescriptive style/legibility rules. They are cheap to check and keep workflows -reviewable; they are necessary but not sufficient (a perfectly styled workflow can still violate -the section 4 contract). - -- **Action pinning.** Pin **every** action - first-party (`actions/*`) and third-party - to a - commit SHA with a trailing `# vX.Y.Z` comment, so a tag swap cannot change executed code while - Renovate/Dependabot can still bump it. Use `# vX` (major-only) only when the upstream floating - major tag has no specific patch SHA. The single documented no-pin exception is a tool whose tag - stream lags `master` such that tag-tracking would propose a downgrade (in this template, - `dotnet/nbgv@master`); do not invent new exceptions, including for repo-owned build-layer leaves. -- **Filename.** Reusable workflows (`on: workflow_call`) end in `-task.yml`. Entry-point workflows - (`on: push` / `pull_request` / `schedule` / `workflow_dispatch`) do **not** use `-task`; they end - with what they do (`-pull-request.yml`, `-release.yml`). The suffix is semantic: a `-task.yml` is - meant to be `uses:`-d, never triggered directly. Names are lowercase, hyphen-separated. -- **Workflow `name:`.** Reusable workflow names end in **"task"** (`Build PyPI library task`); - entry-point names end in **"action"** (`Publish project release action`). The UI label then tells - you at a glance whether you are looking at an orchestrator or a callee. -- **Job and step `name:`.** Every job `name:` ends in **"job"**; every step `name:` ends in - **"step"**. **Exception:** a job whose `name:` is bound as a required-status-check `context:` in a - branch ruleset keeps that exact name verbatim - renaming silently breaks required-check - enforcement. +Prescriptive style/legibility rules. Cheap to check, necessary but not sufficient (a perfectly +styled workflow can still violate section 4). + +- **Action pinning.** Pin **every** action to a commit SHA with a trailing `# vX.Y.Z` comment. + Use `# vX` only when the upstream floating major tag has no specific patch SHA. The single + documented no-pin exception is a tool whose tag stream lags `master` such that tag-tracking would + downgrade (here, `dotnet/nbgv@master`); invent no others. +- **Filename.** Reusable workflows (`on: workflow_call`) end in `-task.yml`; entry-point workflows + do not (`-pull-request.yml`, `-release.yml`). Lowercase, hyphen-separated. +- **Workflow `name:`.** Reusable names end in **"task"**; entry-point names end in **"action"**. +- **Job and step `name:`.** Every job ends in **"job"**, every step in **"step"**. **Exception:** a + job whose `name:` is a ruleset-bound required-check `context:` keeps that exact name. - **Concurrency.** Top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }`. - Document any exception inline (see D7 for the publisher's global, no-cancel group and the - merge-bot's ordered, no-cancel group). -- **Shells.** Every multi-line bash `run:` starts with `set -euo pipefail`. -- **Conditionals.** Multi-line `if:` uses the folded scalar `if: >-` (a literal block `if: |` - embeds newlines into the boolean expression and is wrong). -- **Boolean inputs.** A boolean input used by both `workflow_call` and `workflow_dispatch` is - declared in **both** trigger blocks. `workflow_call` delivers a real boolean; `workflow_dispatch` - delivers the **string** `"true"`/`"false"`. Any `if:` consuming one must compare both forms: - `if: ${{ inputs.foo == true || inputs.foo == 'true' }}`. -- **Reusable-workflow permissions.** Job-level `permissions:` are validated **before** `if:` - evaluates, so even a skipped job needs valid permissions declared. Grant least privilege; for a - reusable callee that needs a scope (e.g. `actions: write` to delete artifacts), the **caller** - grants it at the `uses:` job. -- **Allowlist `success` and `skipped` explicitly** when chaining across optional dependencies. - `!= 'failure'` lets `cancelled` through; use `(needs.X.result == 'success' || needs.X.result == 'skipped')`. -- **Docker layer cache.** Cache to/from a registry tag (`type=registry`, e.g. `buildcache-`), - never the Actions cache (`type=gha`). Multi-image repos use a per-image buildcache tag. -- **Line endings.** Workflow YAML follows `.editorconfig` (CRLF in this template). Committed JSON - state files follow the repo's JSON line-ending rule. Preserve endings on every edit. + Document exceptions inline (D7). +- **Shells.** Every multi-line bash `run:` starts `set -euo pipefail`. +- **Conditionals.** Multi-line `if:` uses the folded scalar `if: >-`. +- **Boolean inputs.** A boolean used by both `workflow_call` and `workflow_dispatch` is declared in + **both** trigger blocks; `workflow_dispatch` delivers the **string** `"true"`/`"false"`, so any + `if:` compares both forms: `${{ inputs.foo == true || inputs.foo == 'true' }}`. +- **Reusable-workflow permissions.** Job-level `permissions:` are validated **before** `if:`, so + even a skipped job needs valid permissions. Grant least privilege; a reusable callee's extra + scope (e.g. `actions: write` for cleanup) is granted by the **caller**. +- **Allowlist `success` and `skipped` explicitly** across optional dependencies (`!= 'failure'` + lets `cancelled` through). +- **Docker layer cache.** Cache to/from a registry tag (`type=registry`), never `type=gha`. +- **Line endings.** Workflow YAML follows `.editorconfig` (CRLF here); committed JSON state files + follow the repo's JSON rule. Preserve endings on every edit. ## 3. Architecture ### Two layers: orchestration vs build -The pipeline splits into a generic **orchestration layer** and a repo-owned **build layer**. - -- **Orchestration** (generic, intended to be synced verbatim): the publish plan and branch matrix, - the version step, the release-tagging/asset-attaching job, the date-badge job, and the - aggregator shape of the PR workflow. It is target-agnostic and should not need per-repo edits. -- **Build** (repo-owned): the `build--task.yml` leaf tasks. A derived project owns and - replaces these; it curates only the *list* of leaf jobs the orchestrator calls. +- **Orchestration** is generic and intended to be synced verbatim **at the job level**: the + publish-plan + branch matrix in the publisher, the `get-version`, `validate-release`, and + `github-release` jobs, the date-badge job, and the `changes -> smoke-build -> aggregator` shape + of the PR workflow. These job *bodies* should not need per-repo edits. +- **Build** is repo-owned: the `build--task.yml` leaf tasks. +- **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` `needs:` entry in + the release task, **and** the `changes` paths-filter entry + output + the `smoke-build` + enable-forward in the PR workflow. "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, a `needs:` entry, and a `library` paths-filter). ### The seam contract A target contributes a file to the GitHub release by uploading a workflow artifact named `release-asset--`. The release job collects **every** matching artifact by -**pattern** (`pattern: release-asset--*` + `merge-multiple: true`), never by an -`artifact-ids:` that names a specific build job's output. This is canonical for **every** repo, -single-target included: name your one asset to the pattern and the verbatim release job globs it. -Switching a single-target repo to an `artifact-id` output forks the release download and breaks the -verbatim carry. +**pattern** (`pattern: release-asset--*` + `merge-multiple: true`), never an `artifact-ids:` +naming one job's output. Canonical for **every** repo, single-target included; switching to an +`artifact-id` handoff forks the release download and breaks the verbatim carry. ### Reusable-task parameter contract -Every `build-*-task.yml` and the release task take: `ref` (git ref to check out/version), `branch` -(the **logical** branch that drives config/tags/prerelease), and where relevant `smoke`. -Branch-derived config keys off `inputs.branch`, **never** `github.ref_name` - the publisher's -matrix builds the non-default branch from a run whose `github.ref_name` is the default branch, so -`ref_name` would be wrong. Artifact names are branch-suffixed so both matrix legs coexist in one -run. +Every leaf and the release task take `ref`, `branch` (the **logical** branch that drives +config/tags/prerelease), and where relevant `smoke`. Branch-derived config keys off `inputs.branch`, +**never** `github.ref_name` (the publisher matrix builds the non-default branch from a run whose +`github.ref_name` is the default branch). Artifact names are branch-suffixed so both legs coexist. ### Versioning -NBGV computes the version. It MUST version from the **checked-out branch**, not the runner's CI ref +NBGV computes the version and MUST version from the **checked-out branch**, not the runner CI ref (set `IGNORE_GITHUB_REF=true`; `GITHUB_REF` is reserved and a step `env:` cannot override it). The -default branch is the public-release ref (`publicReleaseRefSpec`), so it builds a clean public -`X.Y.Z`; every other branch builds a prerelease `X.Y.Z-g`. `version.json`'s `version` is the -major.minor floor; NBGV appends the git height as the patch. A package build derives its registry -version from the same source (PyPI uses a PEP 440 `.devN` suffix off the default branch). A repo -that wraps an upstream release may drive its build/image version from an external committed -`name -> version` state file while NBGV still tags the GitHub release. +default branch is the public-release ref, so it builds clean `X.Y.Z`; every other branch builds a +prerelease `X.Y.Z-g`. `version.json`'s `version` is the major.minor floor; NBGV appends the git +height as the patch. **NBGV and `version.json` are retained even by a repo with no compiled code** - +they are the source of the release tag (`SemVer2`) and `target_commitish` (`GitCommitId`) and the +prerelease classification; the .NET SDK is pulled in only as the versioning toolchain. A package +build derives its registry version from the same NBGV outputs, but **not always from `SemVer2`**: +the PyPI version is built from `AssemblyFileVersion` (four-part `M.N.P.B`) with a PEP 440 `.dev0` +appended on the `develop` branch. A wrapper repo may drive its build/image version from an external +committed `name -> version` state file while NBGV still tags the release. ### Validate-at-entry -When a workflow's inputs must satisfy a cross-input or input-versus-derived-state invariant, assert -it **once** in a dedicated entry job/step that the downstream jobs `needs:`, failing fast before any -build or publish. One gate with a clear `::error::` beats partial checks scattered through later -jobs. +When a workflow's inputs carry a cross-input or input-versus-derived-state invariant, assert it +**once** in a dedicated entry job/step the downstream jobs `needs:`, failing fast with `::error::` +before any build or publish. ### Resource lifecycle Workflow artifacts are an **intra-run handoff** only; durable copies live on the release/registry. -Each transfer artifact is deleted by exact name/pattern **at its point of consumption**, the delete -is **gated to the same condition as the consumer**, it is **best-effort**, and **every** upload sets -`retention-days: 1` as the failure-path backstop. The run is **never** blanket-deleted. See D5. +The rule: a transfer artifact handed **between jobs** is deleted by exact name/pattern **at its +point of consumption**, the delete is **gated to the same condition as the consumer**, and it is +**best-effort**. **Every** `upload-artifact` sets `retention-days: 1` as the universal failure-path +backstop, so no terminal blanket-delete job is needed - and an intermediate consumed only within +the same run (e.g. an executable's per-runtime outputs feeding an aggregation step) may rely on the +retention backstop alone. The run is **never** blanket-deleted (`.artifacts[].id`). See D5. ### Fast PR feedback -PRs validate fast and never publish. A paths-filter detects changed targets and smoke-builds only -those; unit tests always run; smoke builds compile/lint/test but upload nothing and push nothing. -A single required aggregator gates the merge. See D1. +PRs validate fast and never publish: a paths-filter smoke-builds only changed targets; a validation +job always runs; smoke builds compile/lint/test but upload nothing and push nothing; one required +aggregator gates the merge. See D1. ### Release model -Two-phase by default: PRs smoke-test, merges do not publish. The publisher (weekly schedule + -manual dispatch) builds and publishes **both** branches via a matrix; its `push` trigger publishes -only when an opt-in repository variable is set. Every release is a tag on the built commit plus a -source zip, README, and LICENSE; targets amend it with `release-asset-*` files or push to their own -registry. An unchanged version re-pushes nothing (no-op republish); Docker re-pushes by design to -pick up base-image refreshes. See D3/D4. +Two-phase by default: PRs smoke-test, merges do not publish. The publisher (weekly schedule + manual +dispatch) builds and publishes **both** branches via a matrix; its `push` trigger publishes only +when an opt-in repository variable is set. Every release is a tag on the built commit plus a source +zip, README, and LICENSE; targets amend it with `release-asset-*` files or push to their own +registry. An unchanged version re-pushes nothing (no-op republish); Docker re-pushes by design. ### Output seam by destination -Pick each output's path by **where the artifact goes**, not by language: +Pick each output's path by **where the artifact goes**: -- **File on the GitHub release** (zip, binary, packaged library): one leaf task per output, each - uploading `release-asset--`. -- **Package-registry push** (NuGet, PyPI): the leaf builds and the package is published to its - registry. NuGet pushes from the leaf *and* uploads a `release-asset-*`; PyPI is split - the leaf - only builds + uploads its build artifact, and a separate publish job does the OIDC upload (so - `id-token: write` is granted at one entry point) and contributes **no** `release-asset-*`. -- **Image-registry push** (Docker): the leaf pushes multi-arch tags and contributes no +- **File on the GitHub release** (zip, binary, packaged library): one leaf per output uploading + `release-asset--`. The repo keeps `expect_release_assets: true` (its default). +- **Package-registry push** (NuGet, PyPI): the leaf builds and publishes to its registry. NuGet + pushes from the leaf *and* uploads a `release-asset-*`; PyPI is **split** - the leaf only builds + + uploads its build artifact, a separate publish job does the OIDC upload (so `id-token: write` is + granted at one entry point, behind an environment gate) and contributes **no** `release-asset-*`. +- **Image-registry push** (Docker): the leaf pushes multi-arch tags, contributes no `release-asset-*`. -- **Source-only** (validate + tag): no package/image leaf - only validation, optionally one - `release-asset-*`, and the verbatim release orchestration. +- **No file target** (Docker-only, PyPI-only, source-only): the release is tag + source zip + + README + LICENSE. The repo's **caller MUST pass `expect_release_assets: false`** to the release + task (the input is never set by the template's own publisher, which ships file targets and keeps + the default `true`). This is the one case where the otherwise-verbatim publisher is edited; with + the default `true` and no assets, the release-create step fails on `fail_on_unmatched_files`. ## 4. Behavioral contract - expected outcomes The required behaviors, organized by domain. Each is a **MUST**, stated as input -> output plus the -failure-mode it prevents. A workflow that violates any applicable guarantee is **not operational**. +failure-mode it prevents. A workflow that violates any *applicable* guarantee is **not operational**. ### D1 - PR fast-feedback (smoke) - **D1.1 Only changed targets build.** Input: a PR touching some targets. Output: the paths-filter - marks exactly those targets, and only their smoke builds run; unchanged targets skip. *Prevents: - rebuilding everything on every PR, and the inverse - a changed target slipping through unbuilt.* -- **D1.2 Unit tests always run.** Input: any PR. Output: the unit-test job runs unconditionally. -- **D1.3 Smoke never publishes and never uploads.** Input: a smoke build (`smoke: true`). Output: - full compile/lint/test, but **no** registry/image push, **no** release, and **no** artifact - uploads (every `upload-artifact` is gated `!smoke`). *Prevents: a PR publishing, and orphaned - artifacts churning the storage quota.* + marks exactly those targets and only their smoke builds run; unchanged targets skip. A repo's own + targets MUST each have a filter entry (so a touched target is never silently skipped). *Prevents: + rebuilding everything, and a changed target slipping through unbuilt.* +- **D1.2 A validation job always runs.** Input: any PR. Output: a type-appropriate validation job + runs unconditionally and the aggregator `needs:` it. In a .NET repo this is the `unit-test` job + (format/style/test); a non-.NET repo **replaces** it (not deletes) with its own validator (lint, + schema-check) and keeps the aggregator wired to the replacement. *Prevents: a PR merging with no + validation, or a dangling aggregator `needs:`.* +- **D1.3 Smoke never publishes and never uploads.** Input: `smoke: true`. Output: full + compile/lint/test, but no registry/image push, no release, and **no** artifact uploads (every + `upload-artifact`, including any aggregation job, is gated `!smoke`). *Prevents: a PR publishing; + orphaned artifacts churning the storage quota.* - **D1.4 Workflow-file changes are not smoke-built.** Input: a PR changing only - `.github/workflows/**`. Output: the paths-filter excludes workflow files, so smoke-build skips - (a filter cannot tell a logic change from a version-bump). *Implication: there is no CI - workflow-lint; workflow edits MUST be linted locally (actionlint).* -- **D1.5 One required aggregator gates merge.** Input: any PR. Output: a single aggregator job - must **succeed** (not merely "not fail"), `needs:` the changes job, treat a **skipped** smoke - build as pass, and **block** on any `failure`/`cancelled`. Its name is ruleset-bound and MUST NOT - be renamed. *Prevents: a paths-filter error letting a target-changing PR merge with its smoke - build silently skipped.* + `.github/workflows/**`. Output: the paths-filter excludes workflow files, so smoke-build skips. + *Implication: there is no CI workflow-lint; lint workflow edits locally (actionlint).* +- **D1.5 One required aggregator gates merge.** Input: any PR. Output: a single aggregator job must + **succeed**, `needs:` the changes job and the validation job, treat a **skipped** smoke build as + pass, and **block** on `failure`/`cancelled`. Its name is ruleset-bound and MUST NOT be renamed. + *Prevents: a paths-filter error letting a target-changing PR merge unbuilt.* ### D2 - Input/state validation at entry -- **D2.1 Validate before expensive work.** Input: a workflow whose inputs carry a cross-input or - input-versus-derived-state invariant. Output: a dedicated entry job/step asserts it and fails - fast with `::error::` before builds; downstream jobs `needs:` it. *Prevents: cryptic failures - deep in matrix expansion.* +- **D2.1 Validate before expensive work.** Output: a dedicated entry job/step asserts each + cross-input/derived-state invariant and fails fast before builds; downstream jobs `needs:` it. - **D2.2 Release branch matches version classification.** Input: a real (non-smoke) release build. - Output: the gate fails loudly if the default branch carries a prerelease suffix, **or** a - non-default branch carries none. It strips `+buildmetadata` before testing for the prerelease `-` - (only a `-` in the core/prerelease segment counts), and it is **skipped on smoke** (a smoke build - checks out a detached PR head and always versions as prerelease). *Prevents: a non-default leg - published as stable; a build-metadata hyphen false-positive; the gate blocking every - default-base promotion PR.* -- **D2.3 Publish only from the default branch.** Input: a `workflow_dispatch` (or schedule) publish. - Output: a dispatch from a non-default ref fails fast. *Prevents: the matrix building the other - branch leg from the wrong ref and shipping a malformed non-prerelease "Latest".* + Output: the gate fails loudly if the default branch carries a prerelease suffix **or** a + non-default branch carries none; it strips `+buildmetadata` before testing for the prerelease `-` + (only a core/prerelease `-` counts); and it is **skipped on smoke** (a detached PR head always + versions as prerelease). *Prevents: a non-default leg published as stable; a build-metadata + false-positive; the gate blocking every default-base promotion PR.* +- **D2.3 Publish only from the default branch.** Input: a dispatch/schedule publish. Output: a + dispatch from a non-default ref fails fast. *Prevents: the matrix building the other leg from the + wrong ref and shipping a malformed non-prerelease "Latest".* - **D2.4 Mutually-exclusive / paired inputs are validated.** Input: a workflow with either/or or - must-pair inputs (e.g. a repo-list vs a manifest+jq pair). Output: a half-filled or conflicting - combination fails fast. *Prevents: a silent fall-through from a partially specified input.* + must-pair inputs (e.g. the docker-readme task's `repositories` XOR `manifest`+`manifest-jq`). + Output: a half-filled or conflicting combination fails fast. *Prevents: a silent fall-through.* ### D3 - Versioning and classification - **D3.1 Version from the checked-out branch.** Input: a matrix publish dispatched from the default branch, each leg checking out its own branch. Output: each leg's version reflects **its** branch - (`IGNORE_GITHUB_REF=true`), so the non-default leg stays a prerelease. *Prevents: every leg being - classified as the public ref because the CI ref is the default branch.* -- **D3.2 Default = public, others = prerelease.** Output: default branch -> `X.Y.Z` - (`PublicRelease=true`); any other branch -> `X.Y.Z-g` (`PublicRelease=false`). + (`IGNORE_GITHUB_REF=true`). *Prevents: every leg classified as the public ref because the CI ref + is the default branch.* +- **D3.2 Default = public, others = prerelease.** Output: default branch -> `X.Y.Z`; any other -> + `X.Y.Z-g`. The default-branch literal in the gate, the `prerelease` expression, and + `version.json` MUST all name the repo's real default branch. - **D3.3 Version floor + git height.** Output: `version.json` sets the major.minor floor; NBGV - appends the git height as the patch. The floor is bumped only for a functional change, by the - maintainer, in the PR that introduces it - never on a cadence. (No `pathFilters`, so any commit - advances the height.) -- **D3.4 Registry versions follow the same classification.** Output: NuGet default = stable, others - = prerelease; PyPI default = release, others = a PEP 440 `.devN` build. + appends the git height as the patch, bumped only for a functional change by the maintainer. NBGV + and `version.json` are retained even by a no-compiler repo (they own the tag). +- **D3.4 Registry versions follow the classification, per registry.** Output: NuGet default = + stable, others = prerelease (derived by NuGet.org from the SemVer2 `-g` suffix on + `PackageVersion`, not a flag the workflow sets). PyPI builds from `AssemblyFileVersion` + (`M.N.P.B`) and appends `.dev0` on the `develop` branch only (a two-branch literal, not a generic + N-branch rule); the develop `.dev0` build must remain `pip install --pre`-selectable and sort + above the default release (NBGV git height in the release segment keeps develop ahead). + *Prevents: a non-default leg published as a release; a renamed/extra branch silently getting a + plain version.* - **D3.5 Wrapper repos may use an external version.** Output: a repo wrapping an upstream release - drives its build/image version from a committed `name -> version` state file, while NBGV's version - still tags the GitHub release. *Prevents: a wrapper being forced onto its own NBGV version for the - immutable image tag.* + drives its build/image version from a committed `name -> version` state file, while NBGV still + tags the release. *Note: the template ships the tracker (the writer) but no consumer wiring - a + wrapper must wire the leaf to read the state file (e.g. `jq` into the image tag) instead of + `SemVer2`; if the leaf still tags off NBGV, the wrapper is not actually pinned to upstream.* ### D4 - Release / publish - **D4.1 Two-phase by default.** Output: PRs smoke-test; merges do **not** publish unless the opt-in variable is set; the publisher's schedule and dispatch always publish both branches. -- **D4.2 Tag the built commit.** Output: the release's `target_commitish` is the **built commit's - SHA** (NBGV's commit id), never `github.sha` (wrong branch in the publisher matrix) or a moving - branch ref. *Prevents: the release tag landing on the default branch instead of the built tree.* -- **D4.3 Release contents.** Output: every release is a tag on the built commit plus the auto - source zip, README, and LICENSE; file-producing targets attach `release-asset-*`. The prerelease - flag equals `branch != default`. +- **D4.2 Tag the built commit.** Output: the release `target_commitish` is the built commit's SHA + (NBGV's commit id), never `github.sha` or a moving branch ref. *Prevents: the tag landing on the + default branch instead of the built tree.* +- **D4.3 Release contents.** Output: every release is a tag on the built commit plus the auto source + zip, README, and LICENSE; file-producing targets attach `release-asset-*`; `prerelease` equals + `branch != default`. A no-file-target repo reaches the tag-only shape **only** with + `expect_release_assets: false` set by the caller (which relaxes `fail_on_unmatched_files` and + skips the asset download); with the default `true` and no assets the release-create step fails. - **D4.4 No-op republish.** Input: a re-run whose version is unchanged. Output: nothing is - re-pushed - the release-create step is skipped when the tag already exists (and refreshed only on - `workflow_dispatch`); registry pushes use skip-duplicate/skip-existing. **Docker always - re-pushes** by design (base-image refresh). *Prevents: duplicate releases and wasted pushes.* + re-pushed - the release-create step is skipped when the tag exists (refreshed only on + `workflow_dispatch`), and the paired asset-delete is skipped with it; registry pushes are + no-ops. The NuGet/PyPI publish steps are **not** statically gated on existence - they run and the + **server** dedupes (`dotnet nuget push --skip-duplicate` turns a 409 into success; PyPI + `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.* ### D5 - Resource cleanup -- **D5.1 Delete at the point of consumption.** Output: the job that downloads a transfer artifact - deletes it (by exact name/pattern) right after consuming it - including per-runtime intermediates - consumed by an aggregation job. *Prevents: transfer artifacts accumulating against the storage - quota.* +- **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 (e.g. an executable's per-runtime outputs feeding an in-run + aggregation) MAY instead rely on the `retention-days: 1` backstop. *Prevents: transfer artifacts + accumulating against the storage quota.* - **D5.2 Gate the delete to the consumer's condition.** Output: the delete runs under the **same** - condition as the consuming step (e.g. only when a release was actually created/refreshed). *Prevents: - deleting freshly built artifacts on a no-op re-run where nothing consumed them.* -- **D5.3 Best-effort.** Output: cleanup is `continue-on-error`, tolerates a failed artifact - listing, and deletes **all** matching ids. *Prevents: a cleanup hiccup reddening a job whose - publish already succeeded.* -- **D5.4 Retention backstop.** Output: **every** `upload-artifact` sets `retention-days: 1`, so a - job that dies before its consumer leaves nothing beyond a day - no separate terminal cleanup job - is needed. + condition as its consuming step. Where the consumer is conditional (the GitHub release create), + the delete is conditional too; where the consumer always runs when its job runs (the PyPI publish + step), the delete always runs - so on a no-op re-run the `release-asset-*` delete is **skipped** + while the PyPI build-artifact delete still **runs** (its publish ran). *Prevents: deleting freshly + built assets on a no-op re-run.* +- **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 (`.artifacts[].id`). *Prevents: destroying diagnostic/log artifacts and the - build-records actions emit automatically - exactly what you need to debug a failed run.* + artifact set. *Prevents: destroying diagnostic/log artifacts and auto-emitted build-records.* ### D6 - Seam / architecture conformance -- **D6.1 Pattern handoff.** Output: the release job downloads assets by `pattern:`/`merge-multiple:`, - not `artifact-ids:`; targets upload `release-asset--`. Canonical for single-target - repos too. *Prevents: a single-target repo forking the verbatim release download.* +- **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.2 Branch drives config.** Output: branch-derived config reads `inputs.branch`, never - `github.ref_name`. *Prevents: the publisher matrix mislabeling the non-default leg.* -- **D6.3 Branch-suffixed artifacts.** Output: artifact names are branch-suffixed so both matrix legs - coexist in one run. -- **D6.4 Per-target subsetting is clean.** Output: dropping a target removes its leaf task, its job - and `needs` entry in the release task, its path-filter entry, and (for the split PyPI publish) its - publish job - and leaves the release orchestration untouched. + `github.ref_name`. +- **D6.3 Branch-suffixed artifacts.** Output: artifact names are branch-suffixed so both legs + coexist. +- **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` `needs:` entry, the + `changes` paths-filter entry + output, and the `smoke-build` enable-forward (and, for PyPI, the + separate `publish-pypi` 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.* ### D7 - Concurrency, permissions, safety - **D7.1 Publisher serializes.** Output: the publisher uses a **global, ref-independent** concurrency - group with `cancel-in-progress: false`, so a scheduled run and a manual dispatch cannot run - concurrently and double-push, and a mid-flight publish is never cancelled into a partial release. + group with `cancel-in-progress: false`. *Prevents: a schedule and a dispatch double-pushing, or a + cancelled publish leaving a partial release.* - **D7.2 Skipped jobs still need valid permissions.** Output: every reusable job declares valid - `permissions:` (validated before `if:`); a callee's extra scope (e.g. `actions: write` for - cleanup) is granted by the caller. *Prevents: `startup_failure` on a skipped job, or a cleanup - step lacking permission.* -- **D7.3 Boolean inputs both forms.** Output: boolean inputs are declared in both trigger blocks and - compared against `true` and `'true'`. + `permissions:`; 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. +- **D7.3 Boolean inputs both forms.** Output: declared in both trigger blocks, compared against + `true` and `'true'`. - **D7.4 Optional-dependency chaining.** Output: cross-job conditions allowlist `success`/`skipped` - explicitly rather than `!= 'failure'`. + explicitly. ### D8 - Bots / automation -- **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened`; dispatches `--squash` or - `--merge` by the PR's base ref; disables auto-merge on a maintainer-pushed `synchronize`; and its - concurrency is keyed on the **PR number**, not `github.ref`. *Prevents: two PRs colliding in - auto-merge.* +- **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened`; 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 (re-runnable, convergent); Dependabot targets both branches, - with security PRs against the default branch. + deterministic from an external source; Dependabot targets both branches, security PRs to default. - **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 keys that - moved, the merge-bot auto-merges it, and the bump ships on the **next** publish. + object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, + the merge-bot auto-merges it, and the bump ships on the **next** publish. The tracker's + `bump-branch-prefix` + `branches` MUST match the merge-bot's hard-coded `-` head/base + pairs, or auto-merge silently never fires. ### D9 - Style / static (see section 2) -- **D9.1** Every action is SHA-pinned with a version comment (sole exception: the documented +- **D9.1** Every action SHA-pinned with a version comment (sole exception: the documented lagging-tag tool). -- **D9.2** File, workflow, job, and step names follow the suffix rules; ruleset-bound names are - verbatim. +- **D9.2** File/workflow/job/step names follow the suffix rules; ruleset-bound names verbatim. - **D9.3** Bash `run:` blocks start `set -euo pipefail`; multi-line `if:` uses `>-`. -- **D9.4** Docker layer cache targets a registry tag, not `type=gha`. +- **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.5** Line endings follow `.editorconfig`. ## 5. Test methodology -An agent verifies a project against this contract in three escalating modes, then renders a verdict. +An agent verifies a project in three escalating modes, then renders a verdict. **Skip N/A items** +(section 1): a check or scenario for an absent construct is recorded N/A, not failed. ### 5A. Static audit (no execution) -Read the workflow files plus `version.json` and assert the observable structural fact behind each -D-guarantee. Each item is pass/fail with a `file:line` citation. The core checklist: - -- **D1:** a paths-filter `changes` job exists and excludes `.github/workflows/**`; smoke calls the - build task with `smoke: true`, `github: false` (and no registry/image push); every - `upload-artifact` in the build tasks is gated `!smoke`; the required aggregator `needs:` `changes` - and fails on `failure`/`cancelled` while passing on `skipped`. -- **D2:** an entry validation job/step exists for each complex-input workflow; the release gate - checks both directions, strips `+buildmetadata`, and skips on smoke; the publisher rejects a - non-default-ref dispatch; either/or inputs are validated. -- **D3:** the version step sets `IGNORE_GITHUB_REF=true`; `version.json` has `publicReleaseRefSpec` - on the default branch only; PyPI applies a `.devN` off-default. -- **D4:** `target_commitish` is the built commit id; `prerelease` equals `branch != default`; the - release-create step is gated on `exists == false || workflow_dispatch`; registry pushes use - skip-duplicate/skip-existing. -- **D5:** each transfer artifact has a delete step at its consumer, **gated to the consumer's - condition**, `continue-on-error: true`, looping all ids; **every** upload sets +Read the workflow files plus `version.json` and assert the structural fact behind each *applicable* +D-guarantee, each pass/fail/N-A with a `file:line` citation. Remember the two layers: assert each +input in the file that declares it. + +**Core (every repo):** + +- **D1:** a `changes` paths-filter job exists, covers each of the repo's targets, and **excludes** + `.github/workflows/**`; the PR entry workflow's smoke call sets `github/nuget/dockerhub: false` on + the release task; the leaf receives `smoke: true` and a derived `push` (false on smoke); every + build-task `upload-artifact` (and any aggregation job) is gated `!smoke`; the aggregator `needs:` + the `changes` and validation jobs, blocks on `failure`/`cancelled`, passes on `skipped`; a + validation job runs unconditionally. +- **D2:** an entry validation job/step exists per complex-input workflow; the release gate checks + both directions, strips `+buildmetadata`, and skips on smoke; the publisher rejects a + non-default-ref dispatch. +- **D3:** the version step sets `IGNORE_GITHUB_REF=true`; the default-branch literal in the gate + (`== 'main'`), the `prerelease` expression (`!= 'main'`), and `version.json`'s + `publicReleaseRefSpec` all name the repo's actual default branch. +- **D4:** `target_commitish` is the NBGV commit id; `prerelease` equals `branch != default`; the + release-create step is gated `exists == false || workflow_dispatch`; the asset-delete step is + gated identically. +- **D5:** each cross-job transfer artifact has a delete step at its consumer, gated to the + consumer's condition, `continue-on-error: true`, looping all ids; **every** upload sets `retention-days: 1`; **no** `.artifacts[].id` blanket delete exists anywhere. -- **D6:** the release download uses `pattern:`/`merge-multiple:` (no `artifact-ids:`); config reads - `inputs.branch` (grep for `github.ref_name` in branch-derived config is a finding); artifact - names are branch-suffixed. +- **D6:** the release download uses `pattern:`/`merge-multiple:` (no `artifact-ids:`); branch-derived + config reads `inputs.branch` (a `github.ref_name` in such config is a finding); artifact names are + branch-suffixed; the target set is consistent across the release task and the paths-filter. - **D7:** the publisher concurrency group is ref-independent with `cancel-in-progress: false`; reusable jobs declare permissions; boolean `if:` uses both forms. -- **D8/D9:** merge-bot concurrency keys on PR number; actions are SHA-pinned; names/suffixes and - shells/conditionals follow section 2. +- **D8/D9:** merge-bot concurrency keys on PR number; the upstream tracker's branch prefix matches + the merge-bot's head-ref pairs (wrapper repos); actions are SHA-pinned; names/shells/conditionals + follow section 2. + +**Per-type addenda (apply only the ones present):** + +- **Console/executable:** the smoke runtime matrix is a strict non-empty subset of the full matrix; + the per-runtime outputs (`publish--`) are aggregated by `pattern:` + + `merge-multiple:` into one `release-asset--` and the aggregation job is gated + `!smoke`; the per-runtime intermediates rely on the retention backstop (no explicit delete is + required for an in-run intermediate). +- **NuGet:** the publish step is gated `if: inputs.push` only (not on an existence check) and uses + `--skip-duplicate`; `*.nupkg` push also carries the paired `.snupkg` to the symbol server where + symbols are enabled; the `release-asset` zip carries the package(s). +- **PyPI:** `publish-pypi` declares `environment: { name: pypi }`; `id-token: write` appears only on + that job (absent from the build/PR path); `skip-existing: true` is set on the publish action; + the build artifact is deleted after publish; the `pypi` environment has a deployment-branch rule. +- **Docker:** a Docker-only repo's caller passes `expect_release_assets: false`; the leaf reads the + external state file for a wrapper's tag (not `SemVer2`); the readme/date-badge jobs are gated + main-only; the docker-readme task validates `repositories` XOR `manifest`+`manifest-jq`; the + buildcache follows D9.4. ### 5B. End-to-end trace scenarios (no execution, deterministic from the YAML) -For each 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. These -are deterministic from the workflow text - the cheapest way to catch a behavioral defect. Minimum -set (each lists the domains it exercises): +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. +Scenarios that exercise an absent target are N/A. Minimum set: | # | Input | Expected output | Exercises | | --- | --- | --- | --- | -| S1 | PR touching a build target (default-base) | `changes` flags it; `unit-test` runs; that target's smoke build runs (`smoke:true`); no push, **no uploads**; validate-release **skipped (smoke), succeeds**; release job **skipped**; aggregator **success**; version = prerelease; no release; no dangling artifacts | D1, D2.2, D3 | -| S2 | PR changing only docs | smoke-build **skipped**; `unit-test` runs; aggregator **success**; no build/release | D1.1, D1.5 | -| S3 | PR changing only `.github/workflows/**` | filter excludes -> smoke-build **skipped**; aggregator **success** (lint locally) | D1.4 | -| S4 | PR base = default branch, carrying a build target (promotion) | smoke versions as prerelease (detached head); validate-release **skipped (smoke)** so the default-branch arm does **not** fire; aggregator **success**; promotion not blocked | D1.3, D2.2 | -| S5 | push to non-default branch, opt-in variable unset | `setup` -> publish=false; nothing publishes | D4.1 | -| S6 | push to non-default branch, opt-in variable set | publish=true for that branch; it publishes a **prerelease** | D3, D4 | -| S7 | scheduled/dispatched publish from default branch | matrix builds both legs: non-default leg -> `X.Y.Z-g`, release `prerelease=true`, registry prerelease, assets consumed-then-deleted; default leg -> `X.Y.Z`, release `prerelease=false`, registry stable, badge/readme run; **no dangling artifacts** | D3, D4, D5, D6, D7 | -| S8 | `workflow_dispatch` from a non-default ref | `setup` **fails fast** with the guard error | D2.3 | -| S9 | re-run publish, version unchanged (tag exists) | schedule -> release-create **skipped**, paired delete **skipped**, registry pushes no-op; dispatch -> refresh; Docker re-pushes; no duplicate release | D4.4, D5.2 | -| S10 | a build whose branch and version classification disagree | validate-release **fails loud**; build/publish skip; nothing bad ships | D2.2 | +| S1 | PR touching a build target | `changes` flags it; validation runs; that target's smoke build runs; no push, **no uploads**; validate-release **skipped (smoke), succeeds**; release **skipped**; aggregator **success**; version = prerelease; no release; no dangling artifacts | D1, D2.2, D3 | +| S2 | PR changing only docs | smoke-build **skipped**; validation runs; aggregator **success** | D1.1, D1.5 | +| S3 | PR changing only `.github/workflows/**` | filter excludes -> smoke-build **skipped**; aggregator **success** | D1.4 | +| S4 | PR base = default branch, carrying a build target | smoke versions as prerelease; validate-release **skipped (smoke)** so the default-branch arm does **not** fire; aggregator **success**; promotion not blocked | D1.3, D2.2 | +| S5 | push to non-default branch, opt-in unset | `setup` -> publish=false; nothing publishes | D4.1 | +| S6 | push to non-default branch, opt-in set | publish=true; that branch publishes a **prerelease** | D3, D4 | +| S7 | scheduled/dispatched publish from default branch | both legs: non-default -> `X.Y.Z-g`, `prerelease=true`, registry prerelease, `release-asset-*` consumed-then-deleted; default -> `X.Y.Z`, `prerelease=false`, registry stable, badge/readme run; PyPI build-artifact deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 | +| S8 | dispatch from a non-default ref | `setup` **fails fast** | D2.3 | +| S9 | re-run publish, version unchanged | release-create **skipped**, `release-asset-*` delete **skipped**; NuGet/PyPI pushes no-op (server dedupe); **PyPI build-artifact still deleted** (its publish ran); **Docker still re-pushes** the image; no duplicate release | D4.4, D5.2 | +| S10 | branch/version classification disagree | validate-release **fails loud**; build/publish skip | D2.2 | +| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a `-` PR -> merge-bot auto-merges -> the new version ships on the **next** publish | D8.3, D3.5 | ### 5C. Live probe (where warranted) -- Open a trivial-change PR touching one target and confirm S1 in the run. -- Drive a `smoke: true` push-probe of the build task for **both** `branch: ` and - `branch: ` and assert the version classification (clean vs prerelease) and that the - gate passes - **without publishing**. This proves D3.1/D2.2 end to end at near-zero risk. +- Open a trivial-change PR touching one target and confirm S1. +- Drive a `smoke: true` push-probe of the build task for **both** the default and a non-default + branch and assert the version classification (clean vs prerelease) and that the gate passes - + **without publishing**. *Caveat: the Docker leg logs in to the registry even on smoke and reads + the buildcache, so it needs `DOCKER_HUB_*` secrets and cannot run on a fork PR (same-repo only).* +- Per registry: after a real publish, query NuGet.org for the expected version + prerelease + classification (and the `.snupkg` on the symbol server), and confirm a re-run added no duplicate; + for PyPI inspect the `Compute PyPI version step` log and the built `dist/*` filenames for `.dev0` + off `develop` vs a plain version on the default branch. - Inspect the latest real publish's logs for `PublicRelease`/`SemVer2` per leg and confirm the artifact lifecycle (uploaded, consumed, deleted; none left behind). ### Assessment -The workflow is **operational** iff every applicable 5A item passes **and** every 5B scenario's -observed output equals the expected (confirmed by 5C where a live signal is available). Any mismatch -is a **defect** -> **not operational**. Recommended agent procedure: +The workflow is **operational** iff every *applicable* 5A item passes and every *applicable* 5B +scenario's observed output equals the expected (confirmed by 5C where a live signal exists). N/A +items are excluded, never counted as failures. Any *applicable* mismatch is a **defect** -> +**not operational**. Procedure: -1. **Audit** with 5A; record each pass/fail with `file:line`. -2. **Trace** S1-S10 with 5B; diff predicted vs expected tables. -3. **Probe** with 5C only for guarantees a static trace cannot fully settle (live version - classification, real artifact lifecycle). -4. **Verdict:** operational / not operational, with the failing guarantee(s) and the input that - triggers each. +1. **Audit** with 5A; record pass/fail/N-A with `file:line`. +2. **Trace** the applicable S-scenarios with 5B; diff predicted vs expected. +3. **Probe** with 5C only for guarantees a static trace cannot settle (live version classification, + registry state, artifact lifecycle). +4. **Verdict:** operational / not operational, with the failing guarantee(s) and the triggering + input for each, and the list of items recorded N/A. ## 6. Per-project-type test walkthroughs -The template covers several project shapes. Each maps the same S1-S10 onto its targets; the -differences are which leaf tasks exist and what each produces. Walking these is the self-check that -the contract holds for every shape. - -- **Console / executable application.** Target produces `release-asset--executable` (a - zip of `dotnet publish` output) from a per-runtime matrix; the per-runtime intermediates - (`publish--`) are themselves transfer artifacts and MUST be consume-then-deleted - by the aggregation job (D5.1). Test: S1 with a console change smoke-builds a reduced runtime - subset and uploads nothing; S7 attaches the executable zip, prerelease on the non-default leg and - Latest on the default leg. -- **NuGet library.** Target both pushes (`dotnet nuget push --skip-duplicate`) and uploads - `release-asset--nugetlibrary`. Test: S7 non-default leg publishes a **prerelease** package - (`X.Y.Z-g`, `isPrerelease=true`) plus the asset; default leg publishes a stable `X.Y.Z`; S9 - re-run is a `--skip-duplicate` no-op. -- **PyPI library.** Leaf builds + uploads `pypilibrary-build-`; a **separate** publish job - does the OIDC Trusted-Publishing upload (`id-token: write`, an environment gate) and then - **consume-then-deletes** the build artifact; PyPI contributes no `release-asset-*`. Test: S7 - default leg publishes a release version, non-default a PEP 440 `.devN`; S9 re-run is a - `skip-existing` no-op; confirm the build artifact is deleted after publish (D5.1). -- **Docker image.** Leaf pushes multi-arch tags with a registry buildcache; no `release-asset-*` - (so `expect_release_assets` is false - the release is tag + source/README/LICENSE only); the - Docker-Hub readme and date-badge run only when the default branch publishes. Docker **always** - re-pushes (D4.4). A wrapper repo drives the immutable tag from an external version (D3.5). Test: - S7 default leg pushes `latest` + the version tag and updates the readme/badge; non-default pushes - the develop tag; S9 still re-pushes the image. -- **Data / asset library.** A single leaf: validate -> zip -> upload - `release-asset--library`; it drops the nuget/pypi/executable/docker jobs and the PyPI - publish job and keeps the release orchestration verbatim. Test: S7 attaches the zip, prerelease on - the non-default leg. -- **Source-only / no build.** No package/image leaf; validation lives in the PR workflow; the - release is a tag + source zip + README + LICENSE (zero or one `release-asset-*`). Test: S1 runs - validation only; S7 cuts a release with no build asset. +Each type maps the *applicable* S-scenarios onto its targets; the differences are which leaf tasks +exist and what each produces, which 5A addenda apply, and which scenarios are N/A. Walking these is +the self-check that the contract holds for each shape. + +- **Console / executable application.** Target produces `release-asset--executable` (a 7z + archive, `Console.7z`) by building a per-runtime `dotnet publish` matrix, then an aggregation job + downloads the per-runtime `publish--` intermediates (`pattern:` + + `merge-multiple:`), zips them, and uploads the single asset. Smoke builds a strict subset of + runtimes; the per-runtime upload **and** the aggregation job are both gated `!smoke`, so smoke + uploads nothing. The per-runtime intermediates rely on `retention-days: 1` (no explicit delete). + Test: S1 with a console change smoke-builds the subset and uploads nothing; S7 attaches the 7z, + `prerelease=true` on the non-default leg and `prerelease=false` on the default leg (GitHub + auto-marks the stable default release "Latest"; the workflow does not set it). +- **NuGet library.** The leaf both pushes (`dotnet nuget push *.nupkg --skip-duplicate`, gated + `if: push` only) and uploads `release-asset--nugetlibrary`; 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 asset zip 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 library.** The leaf builds + uploads `pypilibrary-build-`; a **separate** + `publish-pypi` job (with `environment: pypi`, `id-token: write`, `actions: write`) does the OIDC + Trusted-Publishing upload with `skip-existing: true`, then **consume-then-deletes** the build + artifact - **unconditionally on consume**, so on S9 it is deleted even though the `release-asset-*` + delete is skipped. The version is `AssemblyFileVersion` with `.dev0` appended on `develop` only, + and must stay `--pre`-selectable and sorted above the default release. PyPI contributes no + `release-asset-*`; a PyPI-only repo sets `expect_release_assets: false` at the caller. Test: S7 + default leg publishes a release, non-default a `.dev0`; S9 is a `skip-existing` no-op; 5C inspects + the `dist/*` filenames and the compute-version log. +- **Docker image.** The leaf pushes multi-arch tags with a per-image registry buildcache (`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 (`peter-evans/dockerhub-description`, + `DOCKER_HUB_ACCESS_TOKEN`) and date-badge jobs run **only** when the default branch publishes; 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 template ships the + tracker but not this consumer wiring). Test: S7 default leg pushes `latest` + the version tag and + updates readme/badge; non-default pushes the develop tag; 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`, upload gated `!smoke` - mirror the + nugetlibrary leaf's shape). Because the template has no such leaf, you **add a target** (D6.4): a + new `enable_library` input + `build-library` job + `github-release` `needs:` entry in the release + task, and a `library` paths-filter entry + `changes` output + `smoke-build` enable-forward in the + PR workflow (without it, D1.1 never smoke-builds the library). Keep `expect_release_assets: true` + (it has a file target, unlike Docker). The .NET `unit-test` job is replaced by a type-appropriate + validator while the aggregator stays wired to it (D1.2/D1.5); `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 release-create + asset-delete + skip, the existing zip is untouched, no registry push. N/A: the nuget/pypi/docker/executable 5A + addenda and their scenario clauses. +- **Source-only / no build.** No package/image leaf: remove all four `build-*` jobs and their + `github-release` `needs:` entries (leaving `get-version -> validate-release -> github-release`, + which fires on `github && !smoke`), and the caller passes `expect_release_assets: false` so the + release is tag + source zip + README + LICENSE with no asset download. With no target the + paths-filter matches nothing, so `smoke-build` is **structurally always skipped** - validation is + carried solely by the (replaced, non-.NET) validation job the aggregator gates on. NBGV and + `version.json` are still retained (they own the tag). Applicable scenarios: S1 (validation only), S5/S6 + (publish gating), S7 (tag-only release), S8 (dispatch guard), S9 (no-op republish), S10 + (classification gate). N/A: S2-S4 (assume a smoke-built target), the artifact-lifecycle and + registry clauses of S7/S9, the D5/D6 artifact items, and all per-type 5A addenda - recorded N/A, + not failed. From 27159cb6109f3454e93d0958d234b94dcf58ff86 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 26 Jun 2026 21:48:19 -0700 Subject: [PATCH 3/5] WORKFLOW.md v3: round-2 precision fixes (docker + data/asset) Round-2 re-review: 4 of 6 assessors returned no actionable feedback; docker and data/asset each raised 2 minors (no blocking/important). Fixes: - Docker: the single-image template buildcache is per-branch, not "per-image" (per-image is the multi-image distinguisher); mark the wrapper external-tag 5A check wrapper-only so it doesn't flag a correct plain Docker repo (records N/A). - Validation-job replacement: a non-.NET repo must re-point BOTH the aggregator and smoke-build (which needs: the validation job by name) to the replacement, else the workflow fails to load (D1.2 generalized; data + source-only noted). - Data/asset S9: qualify "release-create + asset-delete skip" to the scheduled re-run; a workflow_dispatch re-run refreshes and re-runs the asset-delete. Verification: markdownlint clean; CRLF; no issue numbers; dogfood static audit against the template's own workflows stays OPERATIONAL. Co-Authored-By: Claude Opus 4.8 (1M context) --- WORKFLOW.md | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/WORKFLOW.md b/WORKFLOW.md index e28f9cdb..9432be16 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -195,8 +195,9 @@ failure-mode it prevents. A workflow that violates any *applicable* guarantee is - **D1.2 A validation job always runs.** Input: any PR. Output: a type-appropriate validation job runs unconditionally and the aggregator `needs:` it. In a .NET repo this is the `unit-test` job (format/style/test); a non-.NET repo **replaces** it (not deletes) with its own validator (lint, - schema-check) and keeps the aggregator wired to the replacement. *Prevents: a PR merging with no - validation, or a dangling aggregator `needs:`.* + schema-check) and re-points **every** `needs:` on it - both the aggregator and `smoke-build` + (which `needs:` the validation job by name) - to the replacement. *Prevents: a PR merging with no + validation, or a dangling `needs:` that fails the whole workflow to load.* - **D1.3 Smoke never publishes and never uploads.** Input: `smoke: true`. Output: full compile/lint/test, but no registry/image push, no release, and **no** artifact uploads (every `upload-artifact`, including any aggregation job, is gated `!smoke`). *Prevents: a PR publishing; @@ -397,7 +398,8 @@ input in the file that declares it. that job (absent from the build/PR path); `skip-existing: true` is set on the publish action; the build artifact is deleted after publish; the `pypi` environment has a deployment-branch rule. - **Docker:** a Docker-only repo's caller passes `expect_release_assets: false`; the leaf reads the - external state file for a wrapper's tag (not `SemVer2`); the readme/date-badge jobs are gated + external state file for the tag instead of `SemVer2` **(wrapper repos only** - a plain Docker repo + correctly tags off `SemVer2` and records this N/A**)**; the readme/date-badge jobs are gated main-only; the docker-readme task validates `repositories` XOR `manifest`+`manifest-jq`; the buildcache follows D9.4. @@ -481,7 +483,7 @@ the self-check that the contract holds for each shape. `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 multi-arch tags with a per-image registry buildcache (`cache-to` +- **Docker image.** The leaf pushes multi-arch tags 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 (`peter-evans/dockerhub-description`, `DOCKER_HUB_ACCESS_TOKEN`) and date-badge jobs run **only** when the default branch publishes; the @@ -501,17 +503,20 @@ the self-check that the contract holds for each shape. task, and a `library` paths-filter entry + `changes` output + `smoke-build` enable-forward in the PR workflow (without it, D1.1 never smoke-builds the library). Keep `expect_release_assets: true` (it has a file target, unlike Docker). The .NET `unit-test` job is replaced by a type-appropriate - validator while the aggregator stays wired to it (D1.2/D1.5); `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 release-create + asset-delete - skip, the existing zip is untouched, no registry push. N/A: the nuget/pypi/docker/executable 5A + validator with the aggregator **and** `smoke-build` both re-pointed to it (D1.2/D1.5); + `version.json` + the NBGV `get-version` step are retained (they own the tag). Test: S1 smoke runs + validate+zip and uploads nothing; S7 attaches the zip, prerelease on the non-default leg; S9 on a + *scheduled* re-run release-create + asset-delete skip (the existing zip is untouched, no registry + push), while a `workflow_dispatch` re-run **refreshes** the release and re-runs the asset-delete + (the asset is re-uploaded then re-deleted). N/A: the nuget/pypi/docker/executable 5A addenda and their scenario clauses. - **Source-only / no build.** No package/image leaf: remove all four `build-*` jobs and their `github-release` `needs:` entries (leaving `get-version -> validate-release -> github-release`, which fires on `github && !smoke`), and the caller passes `expect_release_assets: false` so the release is tag + source zip + README + LICENSE with no asset download. With no target the paths-filter matches nothing, so `smoke-build` is **structurally always skipped** - validation is - carried solely by the (replaced, non-.NET) validation job the aggregator gates on. NBGV and + carried solely by the (replaced, non-.NET) validation job that the aggregator and `smoke-build`'s + own `needs:` must both point at (D1.2; or drop the never-running `smoke-build` job). NBGV and `version.json` are still retained (they own the tag). Applicable scenarios: S1 (validation only), S5/S6 (publish gating), S7 (tag-only release), S8 (dispatch guard), S9 (no-op republish), S10 (classification gate). N/A: S2-S4 (assume a smoke-built target), the artifact-lifecycle and From dd00572694ce5695abbd97b05ed7d4fd0a1050b3 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 27 Jun 2026 16:25:31 -0700 Subject: [PATCH 4/5] WORKFLOW.md: resolve Copilot review (reflow + present-state wording) - Reflow to one logical paragraph per line (no hard-wrap), per the repo's markdown convention (AGENTS.md "Documentation Style"); content byte-identical modulo whitespace and the collapsed multi-line blockquote. - Rewrite the canonical-scope note in the current state (not as a future ratification plan), stating that AGENTS.md is authoritative where section 2 / release policy overlap and wins on conflict. - Soften the opening ("The guide", not "The single guide"). - Replace the split/mismatched bold markers in the Docker wrapper 5A note with plain parentheses. markdownlint clean; CRLF preserved. Co-Authored-By: Claude Opus 4.8 (1M context) --- WORKFLOW.md | 497 +++++++++++----------------------------------------- 1 file changed, 100 insertions(+), 397 deletions(-) diff --git a/WORKFLOW.md b/WORKFLOW.md index 9432be16..84eb31fa 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -1,413 +1,189 @@ # WORKFLOW.md -The single guide for CI/CD **workflows** (GitHub Actions). It is a deliberate mixture of code -style, architecture, a **behavioral contract** (expected inputs and outputs), and a **test -methodology**. Code style lives in [`CODESTYLE.md`](./CODESTYLE.md); this file is its sibling for -everything under [`.github/workflows/`](./.github/workflows/). +The guide for CI/CD **workflows** (GitHub Actions): a deliberate mixture of code style, architecture, a **behavioral contract** (expected inputs and outputs), and a **test methodology**. Code style lives in [`CODESTYLE.md`](./CODESTYLE.md); this file is its sibling for everything under [`.github/workflows/`](./.github/workflows/). -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 the template byte for byte. The conventions in section 2 keep -workflows legible; the contract in section 4 is what they must *do*. +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 the template byte for byte. The conventions in section 2 keep workflows legible; the contract in section 4 is what they must *do*. Given this document, an agent must be able to do three things to any project: -1. **Audit** - statically check the workflows against the conventions (section 2) and the - structural facts each guarantee implies (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 and every - *applicable* scenario's observed output equals the expected) or **not operational** (any - mismatch - which is a *defect*, not a style nit). +1. **Audit** - statically check the workflows against the conventions (section 2) and the structural facts each guarantee implies (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 and every *applicable* scenario's observed output equals the expected) or **not operational** (any mismatch - which is a *defect*, not a style nit). -> **Reconciliation pending.** Some conventions in section 2 currently also appear in `AGENTS.md` -> ("Workflow YAML Conventions") and the release behavior in `AGENTS.md` "Release Model". This is -> intentional short-term duplication while this document is ratified. Once ratified, the canonical -> copy moves here and `AGENTS.md` points to it (the `CODESTYLE.md` pattern); treat any difference -> as "this file is being adopted," not drift. +> **Canonical scope.** This document is authoritative for the workflow contract and test methodology (sections 3 to 6). The conventions in section 2 and the release policy also live in `AGENTS.md` ("Workflow YAML Conventions" and "Release Model"), which is authoritative where the two overlap; section 2 restates them so this file reads on its own. On any conflict in that overlap, `AGENTS.md` wins. -The guarantees are distilled from failures observed in practice and stated as the **failure-mode -each prevents**, so the document stays portable to any project. +The guarantees are distilled from failures observed in practice and stated as the **failure-mode each prevents**, so the document stays portable to any project. ## 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; the input/output behavior may not. -- **Applicability.** A guarantee (or a 5A check, 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. -- **Default branch.** Guarantees say "default branch" portably; the template implements it 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 (section 5A). -- **Two layers when auditing.** The pipeline splits into an **orchestrator** layer (the PR - entry workflow, the publisher, and the version/release/badge jobs) and a **build-leaf** layer - (`build--task.yml`). Inputs like `github`/`nuget`/`dockerhub`/`expect_release_assets` - live on the orchestrator; a leaf only ever receives `ref`/`branch`/`smoke` (and a derived - `push`). When a check names an input, assert it in the layer that declares it. -- **The three verbs.** Audit (static), Test (trace + probe), Assess (verdict). Section 5 gives the - exact procedure. +- **Contract, not implementation.** Conform to the *outcomes* in section 4. Shape, job names, and file layout may differ between repos; the input/output behavior may not. +- **Applicability.** A guarantee (or a 5A check, 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. +- **Default branch.** Guarantees say "default branch" portably; the template implements it 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 (section 5A). +- **Two layers when auditing.** The pipeline splits into an **orchestrator** layer (the PR entry workflow, the publisher, and the version/release/badge jobs) and a **build-leaf** layer (`build--task.yml`). Inputs like `github`/`nuget`/`dockerhub`/`expect_release_assets` live on the orchestrator; a leaf only ever receives `ref`/`branch`/`smoke` (and a derived `push`). When a check names an input, assert it 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 -Prescriptive style/legibility rules. Cheap to check, necessary but not sufficient (a perfectly -styled workflow can still violate section 4). +Prescriptive style/legibility rules. Cheap to check, necessary but not sufficient (a perfectly styled workflow can still violate section 4). -- **Action pinning.** Pin **every** action to a commit SHA with a trailing `# vX.Y.Z` comment. - Use `# vX` only when the upstream floating major tag has no specific patch SHA. The single - documented no-pin exception is a tool whose tag stream lags `master` such that tag-tracking would - downgrade (here, `dotnet/nbgv@master`); invent no others. -- **Filename.** Reusable workflows (`on: workflow_call`) end in `-task.yml`; entry-point workflows - do not (`-pull-request.yml`, `-release.yml`). Lowercase, hyphen-separated. +- **Action pinning.** Pin **every** action to a commit SHA with a trailing `# vX.Y.Z` comment. Use `# vX` only when the upstream floating major tag has no specific patch SHA. The single documented no-pin exception is a tool whose tag stream lags `master` such that tag-tracking would downgrade (here, `dotnet/nbgv@master`); invent no others. +- **Filename.** Reusable workflows (`on: workflow_call`) end in `-task.yml`; entry-point workflows do not (`-pull-request.yml`, `-release.yml`). Lowercase, hyphen-separated. - **Workflow `name:`.** Reusable names end in **"task"**; entry-point names end in **"action"**. -- **Job and step `name:`.** Every job ends in **"job"**, every step in **"step"**. **Exception:** a - job whose `name:` is a ruleset-bound required-check `context:` keeps that exact name. -- **Concurrency.** Top-level workflows declare - `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }`. - Document exceptions inline (D7). +- **Job and step `name:`.** Every job ends in **"job"**, every step in **"step"**. **Exception:** a job whose `name:` is a ruleset-bound required-check `context:` keeps that exact name. +- **Concurrency.** Top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }`. Document exceptions inline (D7). - **Shells.** Every multi-line bash `run:` starts `set -euo pipefail`. - **Conditionals.** Multi-line `if:` uses the folded scalar `if: >-`. -- **Boolean inputs.** A boolean used by both `workflow_call` and `workflow_dispatch` is declared in - **both** trigger blocks; `workflow_dispatch` delivers the **string** `"true"`/`"false"`, so any - `if:` compares both forms: `${{ inputs.foo == true || inputs.foo == 'true' }}`. -- **Reusable-workflow permissions.** Job-level `permissions:` are validated **before** `if:`, so - even a skipped job needs valid permissions. Grant least privilege; a reusable callee's extra - scope (e.g. `actions: write` for cleanup) is granted by the **caller**. -- **Allowlist `success` and `skipped` explicitly** across optional dependencies (`!= 'failure'` - lets `cancelled` through). +- **Boolean inputs.** A boolean used by both `workflow_call` and `workflow_dispatch` is declared in **both** trigger blocks; `workflow_dispatch` delivers the **string** `"true"`/`"false"`, so any `if:` compares both forms: `${{ inputs.foo == true || inputs.foo == 'true' }}`. +- **Reusable-workflow permissions.** Job-level `permissions:` are validated **before** `if:`, so even a skipped job needs valid permissions. Grant least privilege; a reusable callee's extra scope (e.g. `actions: write` for cleanup) is granted by the **caller**. +- **Allowlist `success` and `skipped` explicitly** across optional dependencies (`!= 'failure'` lets `cancelled` through). - **Docker layer cache.** Cache to/from a registry tag (`type=registry`), never `type=gha`. -- **Line endings.** Workflow YAML follows `.editorconfig` (CRLF here); committed JSON state files - follow the repo's JSON rule. Preserve endings on every edit. +- **Line endings.** Workflow YAML follows `.editorconfig` (CRLF here); committed JSON state files follow the repo's JSON rule. Preserve endings on every edit. ## 3. Architecture ### Two layers: orchestration vs build -- **Orchestration** is generic and intended to be synced verbatim **at the job level**: the - publish-plan + branch matrix in the publisher, the `get-version`, `validate-release`, and - `github-release` jobs, the date-badge job, and the `changes -> smoke-build -> aggregator` shape - of the PR workflow. These job *bodies* should not need per-repo edits. +- **Orchestration** is generic and intended to be synced verbatim **at the job level**: the publish-plan + branch matrix in the publisher, the `get-version`, `validate-release`, and `github-release` jobs, the date-badge job, and the `changes -> smoke-build -> aggregator` shape of the PR workflow. These job *bodies* should not need per-repo edits. - **Build** is repo-owned: the `build--task.yml` leaf tasks. -- **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` `needs:` entry in - the release task, **and** the `changes` paths-filter entry + output + the `smoke-build` - enable-forward in the PR workflow. "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, a `needs:` entry, and a `library` paths-filter). +- **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` `needs:` entry in the release task, **and** the `changes` paths-filter entry + output + the `smoke-build` enable-forward in the PR workflow. "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, a `needs:` entry, and a `library` paths-filter). ### The seam contract -A target contributes a file to the GitHub release by uploading a workflow artifact named -`release-asset--`. The release job collects **every** matching artifact by -**pattern** (`pattern: release-asset--*` + `merge-multiple: true`), never an `artifact-ids:` -naming one job's output. Canonical for **every** repo, single-target included; switching to an -`artifact-id` handoff forks the release download and breaks the verbatim carry. +A target contributes a file to the GitHub release by uploading a workflow artifact named `release-asset--`. The release job collects **every** matching artifact by **pattern** (`pattern: release-asset--*` + `merge-multiple: true`), never an `artifact-ids:` naming one job's output. Canonical for **every** repo, single-target included; switching to an `artifact-id` handoff forks the release download and breaks the verbatim carry. ### Reusable-task parameter contract -Every leaf and the release task take `ref`, `branch` (the **logical** branch that drives -config/tags/prerelease), and where relevant `smoke`. Branch-derived config keys off `inputs.branch`, -**never** `github.ref_name` (the publisher matrix builds the non-default branch from a run whose -`github.ref_name` is the default branch). Artifact names are branch-suffixed so both legs coexist. +Every leaf and the release task take `ref`, `branch` (the **logical** branch that drives config/tags/prerelease), and where relevant `smoke`. Branch-derived config keys off `inputs.branch`, **never** `github.ref_name` (the publisher matrix builds the non-default branch from a run whose `github.ref_name` is the default branch). Artifact names are branch-suffixed so both legs coexist. ### Versioning -NBGV computes the version and MUST version from the **checked-out branch**, not the runner CI ref -(set `IGNORE_GITHUB_REF=true`; `GITHUB_REF` is reserved and a step `env:` cannot override it). The -default branch is the public-release ref, so it builds clean `X.Y.Z`; every other branch builds a -prerelease `X.Y.Z-g`. `version.json`'s `version` is the major.minor floor; NBGV appends the git -height as the patch. **NBGV and `version.json` are retained even by a repo with no compiled code** - -they are the source of the release tag (`SemVer2`) and `target_commitish` (`GitCommitId`) and the -prerelease classification; the .NET SDK is pulled in only as the versioning toolchain. A package -build derives its registry version from the same NBGV outputs, but **not always from `SemVer2`**: -the PyPI version is built from `AssemblyFileVersion` (four-part `M.N.P.B`) with a PEP 440 `.dev0` -appended on the `develop` branch. A wrapper repo may drive its build/image version from an external -committed `name -> version` state file while NBGV still tags the release. +NBGV computes the version and MUST version from the **checked-out branch**, not the runner CI ref (set `IGNORE_GITHUB_REF=true`; `GITHUB_REF` is reserved and a step `env:` cannot override it). The default branch is the public-release ref, so it builds clean `X.Y.Z`; every other branch builds a prerelease `X.Y.Z-g`. `version.json`'s `version` is the major.minor floor; NBGV appends the git height as the patch. **NBGV and `version.json` are retained even by a repo with no compiled code** - they are the source of the release tag (`SemVer2`) and `target_commitish` (`GitCommitId`) and the prerelease classification; the .NET SDK is pulled in only as the versioning toolchain. A package build derives its registry version from the same NBGV outputs, but **not always from `SemVer2`**: the PyPI version is built from `AssemblyFileVersion` (four-part `M.N.P.B`) with a PEP 440 `.dev0` appended on the `develop` branch. A wrapper repo may drive its build/image version from an external committed `name -> version` state file while NBGV still tags the release. ### Validate-at-entry -When a workflow's inputs carry a cross-input or input-versus-derived-state invariant, assert it -**once** in a dedicated entry job/step the downstream jobs `needs:`, failing fast with `::error::` -before any build or publish. +When a workflow's inputs carry a cross-input or input-versus-derived-state invariant, assert it **once** in a dedicated entry job/step the downstream jobs `needs:`, failing fast with `::error::` before any build or publish. ### Resource lifecycle -Workflow artifacts are an **intra-run handoff** only; durable copies live on the release/registry. -The rule: a transfer artifact handed **between jobs** is deleted by exact name/pattern **at its -point of consumption**, the delete is **gated to the same condition as the consumer**, and it is -**best-effort**. **Every** `upload-artifact` sets `retention-days: 1` as the universal failure-path -backstop, so no terminal blanket-delete job is needed - and an intermediate consumed only within -the same run (e.g. an executable's per-runtime outputs feeding an aggregation step) may rely on the -retention backstop alone. The run is **never** blanket-deleted (`.artifacts[].id`). See D5. +Workflow artifacts are an **intra-run handoff** only; durable copies live on the release/registry. The rule: a transfer artifact handed **between jobs** is deleted by exact name/pattern **at its point of consumption**, the delete is **gated to the same condition as the consumer**, and it is **best-effort**. **Every** `upload-artifact` sets `retention-days: 1` as the universal failure-path backstop, so no terminal blanket-delete job is needed - and an intermediate consumed only within the same run (e.g. an executable's per-runtime outputs feeding an aggregation step) may rely on the retention backstop alone. The run is **never** blanket-deleted (`.artifacts[].id`). See D5. ### Fast PR feedback -PRs validate fast and never publish: a paths-filter smoke-builds only changed targets; a validation -job always runs; smoke builds compile/lint/test but upload nothing and push nothing; one required -aggregator gates the merge. See D1. +PRs validate fast and never publish: a paths-filter smoke-builds only changed targets; a validation job always runs; smoke builds compile/lint/test but upload nothing and push nothing; one required aggregator gates the merge. See D1. ### Release model -Two-phase by default: PRs smoke-test, merges do not publish. The publisher (weekly schedule + manual -dispatch) builds and publishes **both** branches via a matrix; its `push` trigger publishes only -when an opt-in repository variable is set. Every release is a tag on the built commit plus a source -zip, README, and LICENSE; targets amend it with `release-asset-*` files or push to their own -registry. An unchanged version re-pushes nothing (no-op republish); Docker re-pushes by design. +Two-phase by default: PRs smoke-test, merges do not publish. The publisher (weekly schedule + manual dispatch) builds and publishes **both** branches via a matrix; its `push` trigger publishes only when an opt-in repository variable is set. Every release is a tag on the built commit plus a source zip, README, and LICENSE; targets amend it with `release-asset-*` files or push to their own registry. An unchanged version re-pushes nothing (no-op republish); Docker re-pushes by design. ### Output seam by destination Pick each output's path by **where the artifact goes**: -- **File on the GitHub release** (zip, binary, packaged library): one leaf per output uploading - `release-asset--`. The repo keeps `expect_release_assets: true` (its default). -- **Package-registry push** (NuGet, PyPI): the leaf builds and publishes to its registry. NuGet - pushes from the leaf *and* uploads a `release-asset-*`; PyPI is **split** - the leaf only builds + - uploads its build artifact, a separate publish job does the OIDC upload (so `id-token: write` is - granted at one entry point, behind an environment gate) and contributes **no** `release-asset-*`. -- **Image-registry push** (Docker): the leaf pushes multi-arch tags, contributes no - `release-asset-*`. -- **No file target** (Docker-only, PyPI-only, source-only): the release is tag + source zip + - README + LICENSE. The repo's **caller MUST pass `expect_release_assets: false`** to the release - task (the input is never set by the template's own publisher, which ships file targets and keeps - the default `true`). This is the one case where the otherwise-verbatim publisher is edited; with - the default `true` and no assets, the release-create step fails on `fail_on_unmatched_files`. +- **File on the GitHub release** (zip, binary, packaged library): one leaf per output uploading `release-asset--`. The repo keeps `expect_release_assets: true` (its default). +- **Package-registry push** (NuGet, PyPI): the leaf builds and publishes to its registry. NuGet pushes from the leaf *and* uploads a `release-asset-*`; PyPI is **split** - the leaf only builds + uploads its build artifact, a separate publish job does the OIDC upload (so `id-token: write` is granted at one entry point, behind an environment gate) and contributes **no** `release-asset-*`. +- **Image-registry push** (Docker): the leaf pushes multi-arch tags, contributes no `release-asset-*`. +- **No file target** (Docker-only, PyPI-only, source-only): the release is tag + source zip + README + LICENSE. The repo's **caller MUST pass `expect_release_assets: false`** to the release task (the input is never set by the template's own publisher, which ships file targets and keeps the default `true`). This is the one case where the otherwise-verbatim publisher is edited; with the default `true` and no assets, the release-create step fails on `fail_on_unmatched_files`. ## 4. Behavioral contract - expected outcomes -The required behaviors, organized by domain. Each is a **MUST**, stated as input -> output plus the -failure-mode it prevents. A workflow that violates any *applicable* guarantee is **not operational**. +The required behaviors, organized by domain. Each is a **MUST**, stated as input -> output plus the failure-mode it prevents. A workflow that violates any *applicable* guarantee is **not operational**. ### D1 - PR fast-feedback (smoke) -- **D1.1 Only changed targets build.** Input: a PR touching some targets. Output: the paths-filter - marks exactly those targets and only their smoke builds run; unchanged targets skip. A repo's own - targets MUST each have a filter entry (so a touched target is never silently skipped). *Prevents: - rebuilding everything, and a changed target slipping through unbuilt.* -- **D1.2 A validation job always runs.** Input: any PR. Output: a type-appropriate validation job - runs unconditionally and the aggregator `needs:` it. In a .NET repo this is the `unit-test` job - (format/style/test); a non-.NET repo **replaces** it (not deletes) with its own validator (lint, - schema-check) and re-points **every** `needs:` on it - both the aggregator and `smoke-build` - (which `needs:` the validation job by name) - to the replacement. *Prevents: a PR merging with no - validation, or a dangling `needs:` that fails the whole workflow to load.* -- **D1.3 Smoke never publishes and never uploads.** Input: `smoke: true`. Output: full - compile/lint/test, but no registry/image push, no release, and **no** artifact uploads (every - `upload-artifact`, including any aggregation job, is gated `!smoke`). *Prevents: a PR publishing; - orphaned artifacts churning the storage quota.* -- **D1.4 Workflow-file changes are not smoke-built.** Input: a PR changing only - `.github/workflows/**`. Output: the paths-filter excludes workflow files, so smoke-build skips. - *Implication: there is no CI workflow-lint; lint workflow edits locally (actionlint).* -- **D1.5 One required aggregator gates merge.** Input: any PR. Output: a single aggregator job must - **succeed**, `needs:` the changes job and the validation job, treat a **skipped** smoke build as - pass, and **block** on `failure`/`cancelled`. Its name is ruleset-bound and MUST NOT be renamed. - *Prevents: a paths-filter error letting a target-changing PR merge unbuilt.* +- **D1.1 Only changed targets build.** Input: a PR touching some targets. Output: the paths-filter marks exactly those targets and only their smoke builds run; unchanged targets skip. A repo's own targets MUST each have a filter entry (so a touched target is never silently skipped). *Prevents: rebuilding everything, and a changed target slipping through unbuilt.* +- **D1.2 A validation job always runs.** Input: any PR. Output: a type-appropriate validation job runs unconditionally and the aggregator `needs:` it. In a .NET repo this is the `unit-test` job (format/style/test); a non-.NET repo **replaces** it (not deletes) with its own validator (lint, schema-check) and re-points **every** `needs:` on it - both the aggregator and `smoke-build` (which `needs:` the validation job by name) - to the replacement. *Prevents: a PR merging with no validation, or a dangling `needs:` that fails the whole workflow to load.* +- **D1.3 Smoke never publishes and never uploads.** Input: `smoke: true`. Output: full compile/lint/test, but no registry/image push, no release, and **no** artifact uploads (every `upload-artifact`, including any aggregation job, is gated `!smoke`). *Prevents: a PR publishing; orphaned artifacts churning the storage quota.* +- **D1.4 Workflow-file changes are not smoke-built.** Input: a PR changing only `.github/workflows/**`. Output: the paths-filter excludes workflow files, so smoke-build skips. *Implication: there is no CI workflow-lint; lint workflow edits locally (actionlint).* +- **D1.5 One required aggregator gates merge.** Input: any PR. Output: a single aggregator job must **succeed**, `needs:` the changes job and the validation job, treat a **skipped** smoke build as pass, and **block** on `failure`/`cancelled`. Its name is ruleset-bound and MUST NOT be renamed. *Prevents: a paths-filter error letting a target-changing PR merge unbuilt.* ### D2 - Input/state validation at entry -- **D2.1 Validate before expensive work.** Output: a dedicated entry job/step asserts each - cross-input/derived-state invariant and fails fast before builds; downstream jobs `needs:` it. -- **D2.2 Release branch matches version classification.** Input: a real (non-smoke) release build. - Output: the gate fails loudly if the default branch carries a prerelease suffix **or** a - non-default branch carries none; it strips `+buildmetadata` before testing for the prerelease `-` - (only a core/prerelease `-` counts); and it is **skipped on smoke** (a detached PR head always - versions as prerelease). *Prevents: a non-default leg published as stable; a build-metadata - false-positive; the gate blocking every default-base promotion PR.* -- **D2.3 Publish only from the default branch.** Input: a dispatch/schedule publish. Output: a - dispatch from a non-default ref fails fast. *Prevents: the matrix building the other leg from the - wrong ref and shipping a malformed non-prerelease "Latest".* -- **D2.4 Mutually-exclusive / paired inputs are validated.** Input: a workflow with either/or or - must-pair inputs (e.g. the docker-readme task's `repositories` XOR `manifest`+`manifest-jq`). - Output: a half-filled or conflicting combination fails fast. *Prevents: a silent fall-through.* +- **D2.1 Validate before expensive work.** Output: a dedicated entry job/step asserts each cross-input/derived-state invariant and fails fast before builds; downstream jobs `needs:` it. +- **D2.2 Release branch matches version classification.** Input: a real (non-smoke) release build. Output: the gate fails loudly if the default branch carries a prerelease suffix **or** a non-default branch carries none; it strips `+buildmetadata` before testing for the prerelease `-` (only a core/prerelease `-` counts); and it is **skipped on smoke** (a detached PR head always versions as prerelease). *Prevents: a non-default leg published as stable; a build-metadata false-positive; the gate blocking every default-base promotion PR.* +- **D2.3 Publish only from the default branch.** Input: a dispatch/schedule publish. Output: a dispatch from a non-default ref fails fast. *Prevents: the matrix building the other leg from the wrong ref and shipping a malformed non-prerelease "Latest".* +- **D2.4 Mutually-exclusive / paired inputs are validated.** Input: a workflow with either/or or must-pair inputs (e.g. the docker-readme task's `repositories` XOR `manifest`+`manifest-jq`). Output: a half-filled or conflicting combination fails fast. *Prevents: a silent fall-through.* ### D3 - Versioning and classification -- **D3.1 Version from the checked-out branch.** Input: a matrix publish dispatched from the default - branch, each leg checking out its own branch. Output: each leg's version reflects **its** branch - (`IGNORE_GITHUB_REF=true`). *Prevents: every leg classified as the public ref because the CI ref - is the default branch.* -- **D3.2 Default = public, others = prerelease.** Output: default branch -> `X.Y.Z`; any other -> - `X.Y.Z-g`. The default-branch literal in the gate, the `prerelease` expression, and - `version.json` MUST all name the repo's real default branch. -- **D3.3 Version floor + git height.** Output: `version.json` sets the major.minor floor; NBGV - appends the git height as the patch, bumped only for a functional change by the maintainer. NBGV - and `version.json` are retained even by a no-compiler repo (they own the tag). -- **D3.4 Registry versions follow the classification, per registry.** Output: NuGet default = - stable, others = prerelease (derived by NuGet.org from the SemVer2 `-g` suffix on - `PackageVersion`, not a flag the workflow sets). PyPI builds from `AssemblyFileVersion` - (`M.N.P.B`) and appends `.dev0` on the `develop` branch only (a two-branch literal, not a generic - N-branch rule); the develop `.dev0` build must remain `pip install --pre`-selectable and sort - above the default release (NBGV git height in the release segment keeps develop ahead). - *Prevents: a non-default leg published as a release; a renamed/extra branch silently getting a - plain version.* -- **D3.5 Wrapper repos may use an external version.** Output: a repo wrapping an upstream release - drives its build/image version from a committed `name -> version` state file, while NBGV still - tags the release. *Note: the template ships the tracker (the writer) but no consumer wiring - a - wrapper must wire the leaf to read the state file (e.g. `jq` into the image tag) instead of - `SemVer2`; if the leaf still tags off NBGV, the wrapper is not actually pinned to upstream.* +- **D3.1 Version from the checked-out branch.** Input: a matrix publish dispatched from the default branch, each leg checking out its own branch. Output: each leg's version reflects **its** branch (`IGNORE_GITHUB_REF=true`). *Prevents: every leg classified as the public ref because the CI ref is the default branch.* +- **D3.2 Default = public, others = prerelease.** Output: default branch -> `X.Y.Z`; any other -> `X.Y.Z-g`. The default-branch literal in the gate, the `prerelease` expression, and `version.json` MUST all name the repo's real default branch. +- **D3.3 Version floor + git height.** Output: `version.json` sets the major.minor floor; NBGV appends the git height as the patch, bumped only for a functional change by the maintainer. NBGV and `version.json` are retained even by a no-compiler repo (they own the tag). +- **D3.4 Registry versions follow the classification, per registry.** Output: NuGet default = stable, others = prerelease (derived by NuGet.org from the SemVer2 `-g` suffix on `PackageVersion`, not a flag the workflow sets). PyPI builds from `AssemblyFileVersion` (`M.N.P.B`) and appends `.dev0` on the `develop` branch only (a two-branch literal, not a generic N-branch rule); the develop `.dev0` build must remain `pip install --pre`-selectable and sort above the default release (NBGV git height in the release segment keeps develop ahead). *Prevents: a non-default leg published as a release; a renamed/extra branch silently getting a plain version.* +- **D3.5 Wrapper repos may use an external version.** Output: a repo wrapping an upstream release drives its build/image version from a committed `name -> version` state file, while NBGV still tags the release. *Note: the template ships the tracker (the writer) but no consumer wiring - a wrapper must wire the leaf to read the state file (e.g. `jq` into the image tag) instead of `SemVer2`; if the leaf still tags off NBGV, the wrapper is not actually pinned to upstream.* ### D4 - Release / publish -- **D4.1 Two-phase by default.** Output: PRs smoke-test; merges do **not** publish unless the opt-in - variable is set; the publisher's schedule and dispatch always publish both branches. -- **D4.2 Tag the built commit.** Output: the release `target_commitish` is the built commit's SHA - (NBGV's commit id), never `github.sha` or a moving branch ref. *Prevents: the tag landing on the - default branch instead of the built tree.* -- **D4.3 Release contents.** Output: every release is a tag on the built commit plus the auto source - zip, README, and LICENSE; file-producing targets attach `release-asset-*`; `prerelease` equals - `branch != default`. A no-file-target repo reaches the tag-only shape **only** with - `expect_release_assets: false` set by the caller (which relaxes `fail_on_unmatched_files` and - skips the asset download); with the default `true` and no assets the release-create step fails. -- **D4.4 No-op republish.** Input: a re-run whose version is unchanged. Output: nothing is - re-pushed - the release-create step is skipped when the tag exists (refreshed only on - `workflow_dispatch`), and the paired asset-delete is skipped with it; registry pushes are - no-ops. The NuGet/PyPI publish steps are **not** statically gated on existence - they run and the - **server** dedupes (`dotnet nuget push --skip-duplicate` turns a 409 into success; PyPI - `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.1 Two-phase by default.** Output: PRs smoke-test; merges do **not** publish unless the opt-in variable is set; the publisher's schedule and dispatch always publish both branches. +- **D4.2 Tag the built commit.** Output: the release `target_commitish` is the built commit's SHA (NBGV's commit id), never `github.sha` or a moving branch ref. *Prevents: the tag landing on the default branch instead of the built tree.* +- **D4.3 Release contents.** Output: every release is a tag on the built commit plus the auto source zip, README, and LICENSE; file-producing targets attach `release-asset-*`; `prerelease` equals `branch != default`. A no-file-target repo reaches the tag-only shape **only** with `expect_release_assets: false` set by the caller (which relaxes `fail_on_unmatched_files` and skips the asset download); with the default `true` and no assets the release-create step fails. +- **D4.4 No-op republish.** Input: a re-run whose version is unchanged. Output: nothing is re-pushed - the release-create step is skipped when the tag exists (refreshed only on `workflow_dispatch`), and the paired asset-delete is skipped with it; registry pushes are no-ops. The NuGet/PyPI publish steps are **not** statically gated on existence - they run and the **server** dedupes (`dotnet nuget push --skip-duplicate` turns a 409 into success; PyPI `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.* ### 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 (e.g. an executable's per-runtime outputs feeding an in-run - aggregation) MAY instead rely on the `retention-days: 1` backstop. *Prevents: transfer artifacts - accumulating against the storage quota.* -- **D5.2 Gate the delete to the consumer's condition.** Output: the delete runs under the **same** - condition as its consuming step. Where the consumer is conditional (the GitHub release create), - the delete is conditional too; where the consumer always runs when its job runs (the PyPI publish - step), the delete always runs - so on a no-op re-run the `release-asset-*` delete is **skipped** - while the PyPI build-artifact delete still **runs** (its publish ran). *Prevents: deleting freshly - built assets on a no-op re-run.* -- **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.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 (e.g. an executable's per-runtime outputs feeding an in-run aggregation) MAY instead rely on the `retention-days: 1` backstop. *Prevents: transfer artifacts accumulating against the storage quota.* +- **D5.2 Gate the delete to the consumer's condition.** Output: the delete runs under the **same** condition as its consuming step. Where the consumer is conditional (the GitHub release create), the delete is conditional too; where the consumer always runs when its job runs (the PyPI publish step), the delete always runs - so on a no-op re-run the `release-asset-*` delete is **skipped** while the PyPI build-artifact delete still **runs** (its publish ran). *Prevents: deleting freshly built assets on a no-op re-run.* +- **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.* +- **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.* ### 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.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 both legs - coexist. -- **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` `needs:` entry, the - `changes` paths-filter entry + output, and the `smoke-build` enable-forward (and, for PyPI, the - separate `publish-pypi` 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.1 Pattern handoff.** Output: the release job downloads by `pattern:`/`merge-multiple:`, not `artifact-ids:`; targets upload `release-asset--`. Canonical for single-target. +- **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 both legs coexist. +- **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` `needs:` entry, the `changes` paths-filter entry + output, and the `smoke-build` enable-forward (and, for PyPI, the separate `publish-pypi` 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.* ### 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 Skipped jobs still need valid permissions.** Output: every reusable job declares valid - `permissions:`; 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. -- **D7.3 Boolean inputs both forms.** Output: declared in both trigger blocks, compared against - `true` and `'true'`. -- **D7.4 Optional-dependency chaining.** Output: cross-job conditions allowlist `success`/`skipped` - explicitly. +- **D7.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 Skipped jobs still need valid permissions.** Output: every reusable job declares valid `permissions:`; 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. +- **D7.3 Boolean inputs both forms.** Output: declared in both trigger blocks, compared against `true` and `'true'`. +- **D7.4 Optional-dependency chaining.** Output: cross-job conditions allowlist `success`/`skipped` explicitly. ### D8 - Bots / automation -- **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened`; 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.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, and the bump ships on the **next** publish. The tracker's - `bump-branch-prefix` + `branches` MUST match the merge-bot's hard-coded `-` head/base - pairs, or auto-merge silently never fires. +- **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened`; 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.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, and the bump ships on the **next** publish. The tracker's `bump-branch-prefix` + `branches` MUST match the merge-bot's hard-coded `-` head/base pairs, or auto-merge silently never fires. ### D9 - Style / static (see section 2) -- **D9.1** Every action SHA-pinned with a version comment (sole exception: the documented - lagging-tag tool). +- **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; ruleset-bound names verbatim. - **D9.3** Bash `run:` blocks start `set -euo 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; multi-image repos use a per-image cache tag. - **D9.5** Line endings follow `.editorconfig`. ## 5. Test methodology -An agent verifies a project in three escalating modes, then renders a verdict. **Skip N/A items** -(section 1): a check or scenario for an absent construct is recorded N/A, not failed. +An agent verifies a project in three escalating modes, then renders a verdict. **Skip N/A items** (section 1): a check or scenario for an absent construct is recorded N/A, not failed. ### 5A. Static audit (no execution) -Read the workflow files plus `version.json` and assert the structural fact behind each *applicable* -D-guarantee, each pass/fail/N-A with a `file:line` citation. Remember the two layers: assert each -input in the file that declares it. +Read the workflow files plus `version.json` and assert the structural fact behind each *applicable* D-guarantee, each pass/fail/N-A with a `file:line` citation. Remember the two layers: assert each input in the file that declares it. **Core (every repo):** -- **D1:** a `changes` paths-filter job exists, covers each of the repo's targets, and **excludes** - `.github/workflows/**`; the PR entry workflow's smoke call sets `github/nuget/dockerhub: false` on - the release task; the leaf receives `smoke: true` and a derived `push` (false on smoke); every - build-task `upload-artifact` (and any aggregation job) is gated `!smoke`; the aggregator `needs:` - the `changes` and validation jobs, blocks on `failure`/`cancelled`, passes on `skipped`; a - validation job runs unconditionally. -- **D2:** an entry validation job/step exists per complex-input workflow; the release gate checks - both directions, strips `+buildmetadata`, and skips on smoke; the publisher rejects a - non-default-ref dispatch. -- **D3:** the version step sets `IGNORE_GITHUB_REF=true`; the default-branch literal in the gate - (`== 'main'`), the `prerelease` expression (`!= 'main'`), and `version.json`'s - `publicReleaseRefSpec` all name the repo's actual default branch. -- **D4:** `target_commitish` is the NBGV commit id; `prerelease` equals `branch != default`; the - release-create step is gated `exists == false || workflow_dispatch`; the asset-delete step is - gated identically. -- **D5:** each cross-job transfer artifact has a delete step at its consumer, gated to the - consumer's condition, `continue-on-error: true`, looping all ids; **every** upload sets - `retention-days: 1`; **no** `.artifacts[].id` blanket delete exists anywhere. -- **D6:** the release download uses `pattern:`/`merge-multiple:` (no `artifact-ids:`); branch-derived - config reads `inputs.branch` (a `github.ref_name` in such config is a finding); artifact names are - branch-suffixed; the target set is consistent across the release task and the paths-filter. -- **D7:** the publisher concurrency group is ref-independent with `cancel-in-progress: false`; - reusable jobs declare permissions; boolean `if:` uses both forms. -- **D8/D9:** merge-bot concurrency keys on PR number; the upstream tracker's branch prefix matches - the merge-bot's head-ref pairs (wrapper repos); actions are SHA-pinned; names/shells/conditionals - follow section 2. +- **D1:** a `changes` paths-filter job exists, covers each of the repo's targets, and **excludes** `.github/workflows/**`; the PR entry workflow's smoke call sets `github/nuget/dockerhub: false` on the release task; the leaf receives `smoke: true` and a derived `push` (false on smoke); every build-task `upload-artifact` (and any aggregation job) is gated `!smoke`; the aggregator `needs:` the `changes` and validation jobs, blocks on `failure`/`cancelled`, passes on `skipped`; a validation job runs unconditionally. +- **D2:** an entry validation job/step exists per complex-input workflow; the release gate checks both directions, strips `+buildmetadata`, and skips on smoke; the publisher rejects a non-default-ref dispatch. +- **D3:** the version step sets `IGNORE_GITHUB_REF=true`; the default-branch literal in the gate (`== 'main'`), the `prerelease` expression (`!= 'main'`), and `version.json`'s `publicReleaseRefSpec` all name the repo's actual default branch. +- **D4:** `target_commitish` is the NBGV commit id; `prerelease` equals `branch != default`; the release-create step is gated `exists == false || workflow_dispatch`; the asset-delete step is gated identically. +- **D5:** each cross-job transfer artifact has a delete step at its consumer, gated to the consumer's condition, `continue-on-error: true`, looping all ids; **every** upload sets `retention-days: 1`; **no** `.artifacts[].id` blanket delete exists anywhere. +- **D6:** the release download uses `pattern:`/`merge-multiple:` (no `artifact-ids:`); branch-derived config reads `inputs.branch` (a `github.ref_name` in such config is a finding); artifact names are branch-suffixed; the target set is consistent across the release task and the paths-filter. +- **D7:** the publisher concurrency group is ref-independent with `cancel-in-progress: false`; reusable jobs declare permissions; boolean `if:` uses both forms. +- **D8/D9:** merge-bot concurrency keys on PR number; the upstream tracker's branch prefix matches the merge-bot's head-ref pairs (wrapper repos); actions are SHA-pinned; names/shells/conditionals follow section 2. **Per-type addenda (apply only the ones present):** -- **Console/executable:** the smoke runtime matrix is a strict non-empty subset of the full matrix; - the per-runtime outputs (`publish--`) are aggregated by `pattern:` + - `merge-multiple:` into one `release-asset--` and the aggregation job is gated - `!smoke`; the per-runtime intermediates rely on the retention backstop (no explicit delete is - required for an in-run intermediate). -- **NuGet:** the publish step is gated `if: inputs.push` only (not on an existence check) and uses - `--skip-duplicate`; `*.nupkg` push also carries the paired `.snupkg` to the symbol server where - symbols are enabled; the `release-asset` zip carries the package(s). -- **PyPI:** `publish-pypi` declares `environment: { name: pypi }`; `id-token: write` appears only on - that job (absent from the build/PR path); `skip-existing: true` is set on the publish action; - the build artifact is deleted after publish; the `pypi` environment has a deployment-branch rule. -- **Docker:** a Docker-only repo's caller passes `expect_release_assets: false`; the leaf reads the - external state file for the tag instead of `SemVer2` **(wrapper repos only** - a plain Docker repo - correctly tags off `SemVer2` and records this N/A**)**; the readme/date-badge jobs are gated - main-only; the docker-readme task validates `repositories` XOR `manifest`+`manifest-jq`; the - buildcache follows D9.4. +- **Console/executable:** the smoke runtime matrix is a strict non-empty subset of the full matrix; the per-runtime outputs (`publish--`) are aggregated by `pattern:` + `merge-multiple:` into one `release-asset--` and the aggregation job is gated `!smoke`; the per-runtime intermediates rely on the retention backstop (no explicit delete is required for an in-run intermediate). +- **NuGet:** the publish step is gated `if: inputs.push` only (not on an existence check) and uses `--skip-duplicate`; `*.nupkg` push also carries the paired `.snupkg` to the symbol server where symbols are enabled; the `release-asset` zip carries the package(s). +- **PyPI:** `publish-pypi` declares `environment: { name: pypi }`; `id-token: write` appears only on that job (absent from the build/PR path); `skip-existing: true` is set on the publish action; the build artifact is deleted after publish; the `pypi` environment has a deployment-branch rule. +- **Docker:** a Docker-only repo's caller passes `expect_release_assets: false`; the leaf reads the external state file for the tag instead of `SemVer2` (wrapper repos only - a plain Docker repo correctly tags off `SemVer2` and records this N/A); the readme/date-badge jobs are gated main-only; the docker-readme task validates `repositories` XOR `manifest`+`manifest-jq`; the buildcache follows D9.4. ### 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. -Scenarios that exercise an absent target are N/A. 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. Scenarios that exercise an absent target are N/A. Minimum set: | # | Input | Expected output | Exercises | | --- | --- | --- | --- | @@ -426,99 +202,26 @@ Scenarios that exercise an absent target are N/A. Minimum set: ### 5C. Live probe (where warranted) - Open a trivial-change PR touching one target and confirm S1. -- Drive a `smoke: true` push-probe of the build task for **both** the default and a non-default - branch and assert the version classification (clean vs prerelease) and that the gate passes - - **without publishing**. *Caveat: the Docker leg logs in to the registry even on smoke and reads - the buildcache, so it needs `DOCKER_HUB_*` secrets and cannot run on a fork PR (same-repo only).* -- Per registry: after a real publish, query NuGet.org for the expected version + prerelease - classification (and the `.snupkg` on the symbol server), and confirm a re-run added no duplicate; - for PyPI inspect the `Compute PyPI version step` log and the built `dist/*` filenames for `.dev0` - off `develop` vs a plain version on the default branch. -- Inspect the latest real publish's logs for `PublicRelease`/`SemVer2` per leg and confirm the - artifact lifecycle (uploaded, consumed, deleted; none left behind). +- Drive a `smoke: true` push-probe of the build task for **both** the default and a non-default branch and assert the version classification (clean vs prerelease) and that the gate passes - **without publishing**. *Caveat: the Docker leg logs in to the registry even on smoke and reads the buildcache, so it needs `DOCKER_HUB_*` secrets and cannot run on a fork PR (same-repo only).* +- Per registry: after a real publish, query NuGet.org for the expected version + prerelease classification (and the `.snupkg` on the symbol server), and confirm a re-run added no duplicate; for PyPI inspect the `Compute PyPI version step` log and the built `dist/*` filenames for `.dev0` off `develop` vs a plain version on the default branch. +- Inspect the latest real publish's logs for `PublicRelease`/`SemVer2` per leg and confirm the artifact lifecycle (uploaded, consumed, deleted; none left behind). ### Assessment -The workflow is **operational** iff every *applicable* 5A item passes and every *applicable* 5B -scenario's observed output equals the expected (confirmed by 5C where a live signal exists). N/A -items are excluded, never counted as failures. Any *applicable* mismatch is a **defect** -> -**not operational**. Procedure: +The workflow is **operational** iff every *applicable* 5A item passes and every *applicable* 5B scenario's observed output equals the expected (confirmed by 5C where a live signal exists). N/A items are excluded, never counted as failures. Any *applicable* mismatch is a **defect** -> **not operational**. Procedure: 1. **Audit** with 5A; record pass/fail/N-A with `file:line`. 2. **Trace** the applicable S-scenarios with 5B; diff predicted vs expected. -3. **Probe** with 5C only for guarantees a static trace cannot settle (live version classification, - registry state, artifact lifecycle). -4. **Verdict:** operational / not operational, with the failing guarantee(s) and the triggering - input for each, and the list of items recorded N/A. +3. **Probe** with 5C only for guarantees a static trace cannot settle (live version classification, registry state, artifact lifecycle). +4. **Verdict:** operational / not operational, with the failing guarantee(s) and the triggering input for each, and the list of items recorded N/A. ## 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, which 5A addenda apply, and which scenarios are N/A. Walking these is -the self-check that the contract holds for each shape. - -- **Console / executable application.** Target produces `release-asset--executable` (a 7z - archive, `Console.7z`) by building a per-runtime `dotnet publish` matrix, then an aggregation job - downloads the per-runtime `publish--` intermediates (`pattern:` + - `merge-multiple:`), zips them, and uploads the single asset. Smoke builds a strict subset of - runtimes; the per-runtime upload **and** the aggregation job are both gated `!smoke`, so smoke - uploads nothing. The per-runtime intermediates rely on `retention-days: 1` (no explicit delete). - Test: S1 with a console change smoke-builds the subset and uploads nothing; S7 attaches the 7z, - `prerelease=true` on the non-default leg and `prerelease=false` on the default leg (GitHub - auto-marks the stable default release "Latest"; the workflow does not set it). -- **NuGet library.** The leaf both pushes (`dotnet nuget push *.nupkg --skip-duplicate`, gated - `if: push` only) and uploads `release-asset--nugetlibrary`; 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 asset zip 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 library.** The leaf builds + uploads `pypilibrary-build-`; a **separate** - `publish-pypi` job (with `environment: pypi`, `id-token: write`, `actions: write`) does the OIDC - Trusted-Publishing upload with `skip-existing: true`, then **consume-then-deletes** the build - artifact - **unconditionally on consume**, so on S9 it is deleted even though the `release-asset-*` - delete is skipped. The version is `AssemblyFileVersion` with `.dev0` appended on `develop` only, - and must stay `--pre`-selectable and sorted above the default release. PyPI contributes no - `release-asset-*`; a PyPI-only repo sets `expect_release_assets: false` at the caller. Test: S7 - default leg publishes a release, non-default a `.dev0`; S9 is a `skip-existing` no-op; 5C inspects - the `dist/*` filenames and the compute-version log. -- **Docker image.** The leaf pushes multi-arch tags 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 (`peter-evans/dockerhub-description`, - `DOCKER_HUB_ACCESS_TOKEN`) and date-badge jobs run **only** when the default branch publishes; 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 template ships the - tracker but not this consumer wiring). Test: S7 default leg pushes `latest` + the version tag and - updates readme/badge; non-default pushes the develop tag; 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`, upload gated `!smoke` - mirror the - nugetlibrary leaf's shape). Because the template has no such leaf, you **add a target** (D6.4): a - new `enable_library` input + `build-library` job + `github-release` `needs:` entry in the release - task, and a `library` paths-filter entry + `changes` output + `smoke-build` enable-forward in the - PR workflow (without it, D1.1 never smoke-builds the library). Keep `expect_release_assets: true` - (it has a file target, unlike Docker). The .NET `unit-test` job is replaced by a type-appropriate - validator with the aggregator **and** `smoke-build` both re-pointed to it (D1.2/D1.5); - `version.json` + the NBGV `get-version` step are retained (they own the tag). Test: S1 smoke runs - validate+zip and uploads nothing; S7 attaches the zip, prerelease on the non-default leg; S9 on a - *scheduled* re-run release-create + asset-delete skip (the existing zip is untouched, no registry - push), while a `workflow_dispatch` re-run **refreshes** the release and re-runs the asset-delete - (the asset is re-uploaded then re-deleted). N/A: the nuget/pypi/docker/executable 5A - addenda and their scenario clauses. -- **Source-only / no build.** No package/image leaf: remove all four `build-*` jobs and their - `github-release` `needs:` entries (leaving `get-version -> validate-release -> github-release`, - which fires on `github && !smoke`), and the caller passes `expect_release_assets: false` so the - release is tag + source zip + README + LICENSE with no asset download. With no target the - paths-filter matches nothing, so `smoke-build` is **structurally always skipped** - validation is - carried solely by the (replaced, non-.NET) validation job that the aggregator and `smoke-build`'s - own `needs:` must both point at (D1.2; or drop the never-running `smoke-build` job). NBGV and - `version.json` are still retained (they own the tag). Applicable scenarios: S1 (validation only), S5/S6 - (publish gating), S7 (tag-only release), S8 (dispatch guard), S9 (no-op republish), S10 - (classification gate). N/A: S2-S4 (assume a smoke-built target), the artifact-lifecycle and - registry clauses of S7/S9, the D5/D6 artifact items, and all per-type 5A addenda - recorded N/A, - not failed. +Each type maps the *applicable* S-scenarios onto its targets; the differences are which leaf tasks exist and what each produces, which 5A addenda apply, and which scenarios are N/A. Walking these is the self-check that the contract holds for each shape. + +- **Console / executable application.** Target produces `release-asset--executable` (a 7z archive, `Console.7z`) by building a per-runtime `dotnet publish` matrix, then an aggregation job downloads the per-runtime `publish--` intermediates (`pattern:` + `merge-multiple:`), zips them, and uploads the single asset. Smoke builds a strict subset of runtimes; the per-runtime upload **and** the aggregation job are both gated `!smoke`, so smoke uploads nothing. The per-runtime intermediates rely on `retention-days: 1` (no explicit delete). Test: S1 with a console change smoke-builds the subset and uploads nothing; S7 attaches the 7z, `prerelease=true` on the non-default leg and `prerelease=false` on the default leg (GitHub auto-marks the stable default release "Latest"; the workflow does not set it). +- **NuGet library.** The leaf both pushes (`dotnet nuget push *.nupkg --skip-duplicate`, gated `if: push` only) and uploads `release-asset--nugetlibrary`; 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 asset zip 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 library.** The leaf builds + uploads `pypilibrary-build-`; a **separate** `publish-pypi` job (with `environment: pypi`, `id-token: write`, `actions: write`) does the OIDC Trusted-Publishing upload with `skip-existing: true`, then **consume-then-deletes** the build artifact - **unconditionally on consume**, so on S9 it is deleted even though the `release-asset-*` delete is skipped. The version is `AssemblyFileVersion` with `.dev0` appended on `develop` only, and must stay `--pre`-selectable and sorted above the default release. PyPI contributes no `release-asset-*`; a PyPI-only repo sets `expect_release_assets: false` at the caller. Test: S7 default leg publishes a release, non-default a `.dev0`; S9 is a `skip-existing` no-op; 5C inspects the `dist/*` filenames and the compute-version log. +- **Docker image.** The leaf pushes multi-arch tags 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 (`peter-evans/dockerhub-description`, `DOCKER_HUB_ACCESS_TOKEN`) and date-badge jobs run **only** when the default branch publishes; 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 template ships the tracker but not this consumer wiring). Test: S7 default leg pushes `latest` + the version tag and updates readme/badge; non-default pushes the develop tag; 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`, upload gated `!smoke` - mirror the nugetlibrary leaf's shape). Because the template has no such leaf, you **add a target** (D6.4): a new `enable_library` input + `build-library` job + `github-release` `needs:` entry in the release task, and a `library` paths-filter entry + `changes` output + `smoke-build` enable-forward in the PR workflow (without it, D1.1 never smoke-builds the library). Keep `expect_release_assets: true` (it has a file target, unlike Docker). The .NET `unit-test` job is replaced by a type-appropriate validator with the aggregator **and** `smoke-build` both re-pointed to it (D1.2/D1.5); `version.json` + the NBGV `get-version` step are retained (they own the tag). Test: S1 smoke runs validate+zip and uploads nothing; S7 attaches the zip, prerelease on the non-default leg; S9 on a *scheduled* re-run release-create + asset-delete skip (the existing zip is untouched, no registry push), while a `workflow_dispatch` re-run **refreshes** the release and re-runs the asset-delete (the asset is re-uploaded then re-deleted). N/A: the nuget/pypi/docker/executable 5A addenda and their scenario clauses. +- **Source-only / no build.** No package/image leaf: remove all four `build-*` jobs and their `github-release` `needs:` entries (leaving `get-version -> validate-release -> github-release`, which fires on `github && !smoke`), and the caller passes `expect_release_assets: false` so the release is tag + source zip + README + LICENSE with no asset download. With no target the paths-filter matches nothing, so `smoke-build` is **structurally always skipped** - validation is carried solely by the (replaced, non-.NET) validation job that the aggregator and `smoke-build`'s own `needs:` must both point at (D1.2; or drop the never-running `smoke-build` job). NBGV and `version.json` are still retained (they own the tag). Applicable scenarios: S1 (validation only), S5/S6 (publish gating), S7 (tag-only release), S8 (dispatch guard), S9 (no-op republish), S10 (classification gate). N/A: S2-S4 (assume a smoke-built target), the artifact-lifecycle and registry clauses of S7/S9, the D5/D6 artifact items, and all per-type 5A addenda - recorded N/A, not failed. From 0ac0b2d0746f459a27a135dca13850ee2860362c Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 4 Jul 2026 10:17:16 -0700 Subject: [PATCH 5/5] WORKFLOW.md: title-case headings per repo convention Resolves Copilot review: headings follow the title-case-with-short-bind-words rule (AGENTS.md), matching sibling CODESTYLE.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- WORKFLOW.md | 50 +++++++++++++++++++++++++------------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/WORKFLOW.md b/WORKFLOW.md index 84eb31fa..c985e47a 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -14,7 +14,7 @@ Given this document, an agent must be able to do three things to any project: The guarantees are distilled from failures observed in practice and stated as the **failure-mode each prevents**, so the document stays portable to any project. -## 1. Purpose and how to use this document +## 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; the input/output behavior may not. - **Applicability.** A guarantee (or a 5A check, 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. @@ -23,7 +23,7 @@ The guarantees are distilled from failures observed in practice and stated as th - **Two layers when auditing.** The pipeline splits into an **orchestrator** layer (the PR entry workflow, the publisher, and the version/release/badge jobs) and a **build-leaf** layer (`build--task.yml`). Inputs like `github`/`nuget`/`dockerhub`/`expect_release_assets` live on the orchestrator; a leaf only ever receives `ref`/`branch`/`smoke` (and a derived `push`). When a check names an input, assert it 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 +## 2. Workflow Style Conventions Prescriptive style/legibility rules. Cheap to check, necessary but not sufficient (a perfectly styled workflow can still violate section 4). @@ -42,17 +42,17 @@ Prescriptive style/legibility rules. Cheap to check, necessary but not sufficien ## 3. Architecture -### Two layers: orchestration vs build +### Two Layers: Orchestration vs Build - **Orchestration** is generic and intended to be synced verbatim **at the job level**: the publish-plan + branch matrix in the publisher, the `get-version`, `validate-release`, and `github-release` jobs, the date-badge job, and the `changes -> smoke-build -> aggregator` shape of the PR workflow. These job *bodies* should not need per-repo edits. - **Build** is repo-owned: the `build--task.yml` leaf tasks. - **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` `needs:` entry in the release task, **and** the `changes` paths-filter entry + output + the `smoke-build` enable-forward in the PR workflow. "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, a `needs:` entry, and a `library` paths-filter). -### The seam contract +### The Seam Contract A target contributes a file to the GitHub release by uploading a workflow artifact named `release-asset--`. The release job collects **every** matching artifact by **pattern** (`pattern: release-asset--*` + `merge-multiple: true`), never an `artifact-ids:` naming one job's output. Canonical for **every** repo, single-target included; switching to an `artifact-id` handoff forks the release download and breaks the verbatim carry. -### Reusable-task parameter contract +### Reusable-Task Parameter Contract Every leaf and the release task take `ref`, `branch` (the **logical** branch that drives config/tags/prerelease), and where relevant `smoke`. Branch-derived config keys off `inputs.branch`, **never** `github.ref_name` (the publisher matrix builds the non-default branch from a run whose `github.ref_name` is the default branch). Artifact names are branch-suffixed so both legs coexist. @@ -60,23 +60,23 @@ Every leaf and the release task take `ref`, `branch` (the **logical** branch tha NBGV computes the version and MUST version from the **checked-out branch**, not the runner CI ref (set `IGNORE_GITHUB_REF=true`; `GITHUB_REF` is reserved and a step `env:` cannot override it). The default branch is the public-release ref, so it builds clean `X.Y.Z`; every other branch builds a prerelease `X.Y.Z-g`. `version.json`'s `version` is the major.minor floor; NBGV appends the git height as the patch. **NBGV and `version.json` are retained even by a repo with no compiled code** - they are the source of the release tag (`SemVer2`) and `target_commitish` (`GitCommitId`) and the prerelease classification; the .NET SDK is pulled in only as the versioning toolchain. A package build derives its registry version from the same NBGV outputs, but **not always from `SemVer2`**: the PyPI version is built from `AssemblyFileVersion` (four-part `M.N.P.B`) with a PEP 440 `.dev0` appended on the `develop` branch. A wrapper repo may drive its build/image version from an external committed `name -> version` state file while NBGV still tags the release. -### Validate-at-entry +### Validate-at-Entry When a workflow's inputs carry a cross-input or input-versus-derived-state invariant, assert it **once** in a dedicated entry job/step the downstream jobs `needs:`, failing fast with `::error::` before any build or publish. -### Resource lifecycle +### Resource Lifecycle Workflow artifacts are an **intra-run handoff** only; durable copies live on the release/registry. The rule: a transfer artifact handed **between jobs** is deleted by exact name/pattern **at its point of consumption**, the delete is **gated to the same condition as the consumer**, and it is **best-effort**. **Every** `upload-artifact` sets `retention-days: 1` as the universal failure-path backstop, so no terminal blanket-delete job is needed - and an intermediate consumed only within the same run (e.g. an executable's per-runtime outputs feeding an aggregation step) may rely on the retention backstop alone. The run is **never** blanket-deleted (`.artifacts[].id`). See D5. -### Fast PR feedback +### Fast PR Feedback PRs validate fast and never publish: a paths-filter smoke-builds only changed targets; a validation job always runs; smoke builds compile/lint/test but upload nothing and push nothing; one required aggregator gates the merge. See D1. -### Release model +### Release Model Two-phase by default: PRs smoke-test, merges do not publish. The publisher (weekly schedule + manual dispatch) builds and publishes **both** branches via a matrix; its `push` trigger publishes only when an opt-in repository variable is set. Every release is a tag on the built commit plus a source zip, README, and LICENSE; targets amend it with `release-asset-*` files or push to their own registry. An unchanged version re-pushes nothing (no-op republish); Docker re-pushes by design. -### Output seam by destination +### Output Seam by Destination Pick each output's path by **where the artifact goes**: @@ -85,11 +85,11 @@ Pick each output's path by **where the artifact goes**: - **Image-registry push** (Docker): the leaf pushes multi-arch tags, contributes no `release-asset-*`. - **No file target** (Docker-only, PyPI-only, source-only): the release is tag + source zip + README + LICENSE. The repo's **caller MUST pass `expect_release_assets: false`** to the release task (the input is never set by the template's own publisher, which ships file targets and keeps the default `true`). This is the one case where the otherwise-verbatim publisher is edited; with the default `true` and no assets, the release-create step fails on `fail_on_unmatched_files`. -## 4. Behavioral contract - expected outcomes +## 4. Behavioral Contract - Expected Outcomes The required behaviors, organized by domain. Each is a **MUST**, stated as input -> output plus the failure-mode it prevents. A workflow that violates any *applicable* guarantee is **not operational**. -### D1 - PR fast-feedback (smoke) +### D1 - PR Fast-Feedback (Smoke) - **D1.1 Only changed targets build.** Input: a PR touching some targets. Output: the paths-filter marks exactly those targets and only their smoke builds run; unchanged targets skip. A repo's own targets MUST each have a filter entry (so a touched target is never silently skipped). *Prevents: rebuilding everything, and a changed target slipping through unbuilt.* - **D1.2 A validation job always runs.** Input: any PR. Output: a type-appropriate validation job runs unconditionally and the aggregator `needs:` it. In a .NET repo this is the `unit-test` job (format/style/test); a non-.NET repo **replaces** it (not deletes) with its own validator (lint, schema-check) and re-points **every** `needs:` on it - both the aggregator and `smoke-build` (which `needs:` the validation job by name) - to the replacement. *Prevents: a PR merging with no validation, or a dangling `needs:` that fails the whole workflow to load.* @@ -97,14 +97,14 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input - **D1.4 Workflow-file changes are not smoke-built.** Input: a PR changing only `.github/workflows/**`. Output: the paths-filter excludes workflow files, so smoke-build skips. *Implication: there is no CI workflow-lint; lint workflow edits locally (actionlint).* - **D1.5 One required aggregator gates merge.** Input: any PR. Output: a single aggregator job must **succeed**, `needs:` the changes job and the validation job, treat a **skipped** smoke build as pass, and **block** on `failure`/`cancelled`. Its name is ruleset-bound and MUST NOT be renamed. *Prevents: a paths-filter error letting a target-changing PR merge unbuilt.* -### D2 - Input/state validation at entry +### D2 - Input/State Validation at Entry - **D2.1 Validate before expensive work.** Output: a dedicated entry job/step asserts each cross-input/derived-state invariant and fails fast before builds; downstream jobs `needs:` it. - **D2.2 Release branch matches version classification.** Input: a real (non-smoke) release build. Output: the gate fails loudly if the default branch carries a prerelease suffix **or** a non-default branch carries none; it strips `+buildmetadata` before testing for the prerelease `-` (only a core/prerelease `-` counts); and it is **skipped on smoke** (a detached PR head always versions as prerelease). *Prevents: a non-default leg published as stable; a build-metadata false-positive; the gate blocking every default-base promotion PR.* - **D2.3 Publish only from the default branch.** Input: a dispatch/schedule publish. Output: a dispatch from a non-default ref fails fast. *Prevents: the matrix building the other leg from the wrong ref and shipping a malformed non-prerelease "Latest".* - **D2.4 Mutually-exclusive / paired inputs are validated.** Input: a workflow with either/or or must-pair inputs (e.g. the docker-readme task's `repositories` XOR `manifest`+`manifest-jq`). Output: a half-filled or conflicting combination fails fast. *Prevents: a silent fall-through.* -### D3 - Versioning and classification +### D3 - Versioning and Classification - **D3.1 Version from the checked-out branch.** Input: a matrix publish dispatched from the default branch, each leg checking out its own branch. Output: each leg's version reflects **its** branch (`IGNORE_GITHUB_REF=true`). *Prevents: every leg classified as the public ref because the CI ref is the default branch.* - **D3.2 Default = public, others = prerelease.** Output: default branch -> `X.Y.Z`; any other -> `X.Y.Z-g`. The default-branch literal in the gate, the `prerelease` expression, and `version.json` MUST all name the repo's real default branch. @@ -112,14 +112,14 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input - **D3.4 Registry versions follow the classification, per registry.** Output: NuGet default = stable, others = prerelease (derived by NuGet.org from the SemVer2 `-g` suffix on `PackageVersion`, not a flag the workflow sets). PyPI builds from `AssemblyFileVersion` (`M.N.P.B`) and appends `.dev0` on the `develop` branch only (a two-branch literal, not a generic N-branch rule); the develop `.dev0` build must remain `pip install --pre`-selectable and sort above the default release (NBGV git height in the release segment keeps develop ahead). *Prevents: a non-default leg published as a release; a renamed/extra branch silently getting a plain version.* - **D3.5 Wrapper repos may use an external version.** Output: a repo wrapping an upstream release drives its build/image version from a committed `name -> version` state file, while NBGV still tags the release. *Note: the template ships the tracker (the writer) but no consumer wiring - a wrapper must wire the leaf to read the state file (e.g. `jq` into the image tag) instead of `SemVer2`; if the leaf still tags off NBGV, the wrapper is not actually pinned to upstream.* -### D4 - Release / publish +### D4 - Release / Publish - **D4.1 Two-phase by default.** Output: PRs smoke-test; merges do **not** publish unless the opt-in variable is set; the publisher's schedule and dispatch always publish both branches. - **D4.2 Tag the built commit.** Output: the release `target_commitish` is the built commit's SHA (NBGV's commit id), never `github.sha` or a moving branch ref. *Prevents: the tag landing on the default branch instead of the built tree.* - **D4.3 Release contents.** Output: every release is a tag on the built commit plus the auto source zip, README, and LICENSE; file-producing targets attach `release-asset-*`; `prerelease` equals `branch != default`. A no-file-target repo reaches the tag-only shape **only** with `expect_release_assets: false` set by the caller (which relaxes `fail_on_unmatched_files` and skips the asset download); with the default `true` and no assets the release-create step fails. - **D4.4 No-op republish.** Input: a re-run whose version is unchanged. Output: nothing is re-pushed - the release-create step is skipped when the tag exists (refreshed only on `workflow_dispatch`), and the paired asset-delete is skipped with it; registry pushes are no-ops. The NuGet/PyPI publish steps are **not** statically gated on existence - they run and the **server** dedupes (`dotnet nuget push --skip-duplicate` turns a 409 into success; PyPI `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.* -### D5 - Resource cleanup +### 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 (e.g. an executable's per-runtime outputs feeding an in-run aggregation) MAY instead rely on the `retention-days: 1` backstop. *Prevents: transfer artifacts accumulating against the storage quota.* - **D5.2 Gate the delete to the consumer's condition.** Output: the delete runs under the **same** condition as its consuming step. Where the consumer is conditional (the GitHub release create), the delete is conditional too; where the consumer always runs when its job runs (the PyPI publish step), the delete always runs - so on a no-op re-run the `release-asset-*` delete is **skipped** while the PyPI build-artifact delete still **runs** (its publish ran). *Prevents: deleting freshly built assets on a no-op re-run.* @@ -127,27 +127,27 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input - **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.* -### D6 - Seam / architecture conformance +### 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.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 both legs coexist. - **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` `needs:` entry, the `changes` paths-filter entry + output, and the `smoke-build` enable-forward (and, for PyPI, the separate `publish-pypi` 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.* -### D7 - Concurrency, permissions, safety +### 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 Skipped jobs still need valid permissions.** Output: every reusable job declares valid `permissions:`; 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. - **D7.3 Boolean inputs both forms.** Output: declared in both trigger blocks, compared against `true` and `'true'`. - **D7.4 Optional-dependency chaining.** Output: cross-job conditions allowlist `success`/`skipped` explicitly. -### D8 - Bots / automation +### D8 - Bots / Automation - **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened`; 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.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, and the bump ships on the **next** publish. The tracker's `bump-branch-prefix` + `branches` MUST match the merge-bot's hard-coded `-` head/base pairs, or auto-merge silently never fires. -### D9 - Style / static (see section 2) +### D9 - Style / Static (See Section 2) - **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; ruleset-bound names verbatim. @@ -155,11 +155,11 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input - **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.5** Line endings follow `.editorconfig`. -## 5. Test methodology +## 5. Test Methodology An agent verifies a project in three escalating modes, then renders a verdict. **Skip N/A items** (section 1): a check or scenario for an absent construct is recorded N/A, not failed. -### 5A. Static audit (no execution) +### 5A. Static Audit (No Execution) Read the workflow files plus `version.json` and assert the structural fact behind each *applicable* D-guarantee, each pass/fail/N-A with a `file:line` citation. Remember the two layers: assert each input in the file that declares it. @@ -181,7 +181,7 @@ Read the workflow files plus `version.json` and assert the structural fact behin - **PyPI:** `publish-pypi` declares `environment: { name: pypi }`; `id-token: write` appears only on that job (absent from the build/PR path); `skip-existing: true` is set on the publish action; the build artifact is deleted after publish; the `pypi` environment has a deployment-branch rule. - **Docker:** a Docker-only repo's caller passes `expect_release_assets: false`; the leaf reads the external state file for the tag instead of `SemVer2` (wrapper repos only - a plain Docker repo correctly tags off `SemVer2` and records this N/A); the readme/date-badge jobs are gated main-only; the docker-readme task validates `repositories` XOR `manifest`+`manifest-jq`; the buildcache follows D9.4. -### 5B. End-to-end trace scenarios (no execution, deterministic from the YAML) +### 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. Scenarios that exercise an absent target are N/A. Minimum set: @@ -199,7 +199,7 @@ For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the | S10 | branch/version classification disagree | validate-release **fails loud**; build/publish skip | D2.2 | | S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a `-` PR -> merge-bot auto-merges -> the new version ships on the **next** publish | D8.3, D3.5 | -### 5C. Live probe (where warranted) +### 5C. Live Probe (Where Warranted) - Open a trivial-change PR touching one target and confirm S1. - Drive a `smoke: true` push-probe of the build task for **both** the default and a non-default branch and assert the version classification (clean vs prerelease) and that the gate passes - **without publishing**. *Caveat: the Docker leg logs in to the registry even on smoke and reads the buildcache, so it needs `DOCKER_HUB_*` secrets and cannot run on a fork PR (same-repo only).* @@ -215,7 +215,7 @@ The workflow is **operational** iff every *applicable* 5A item passes and every 3. **Probe** with 5C only for guarantees a static trace cannot settle (live version classification, registry state, artifact lifecycle). 4. **Verdict:** operational / not operational, with the failing guarantee(s) and the triggering input for each, and the list of items recorded N/A. -## 6. Per-project-type test walkthroughs +## 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, which 5A addenda apply, and which scenarios are N/A. Walking these is the self-check that the contract holds for each shape.