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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ publisher passes `branch: ${{ github.ref_name }}`, which the tasks forward and r
`build-release-task.yml` is a hub-hosted task with per-target `enable_*` inputs, so a repo drops a
target by setting its `enable_<target>: false` at the caller stub rather than deleting a job: the
hub task carries the full job graph for every repo, and the caller stub's `with:` block is where
the target list is expressed. A repo still curates its path-filter entry in
`test-pull-request.yml`, and (for PyPI) the `publish-pypi` job in its own `publish-release.yml`,
since `id-token: write` belongs at that one entry point. CodeGen, versioning, badge, merge-bot,
and Dependabot are target-agnostic.
the target list is expressed. A repo still curates, in `test-pull-request.yml`, its
path-filter entry, that filter's output, and the `smoke-build` enable-forward, all three together
per D6.4, since an entry nothing consumes never smoke-builds the target. And, for a package
target, the `publish-nuget` or `publish-pypi` job in its own `publish-release.yml`, since
`id-token: write` belongs at that one entry point. CodeGen, versioning, merge-bot, and Dependabot
are target-agnostic.

## Orchestration vs. build: the override seam

Expand Down Expand Up @@ -62,22 +64,30 @@ and project-path inputs its targets need.
Pick by where each artifact *goes*, not by language:

- **Files attached to the GitHub Release** (zips, binaries, packaged libraries): a dotnet-publish
hook or a build-nuget hook per output, each uploading `release-asset-<branch>-<name>`. This is where the
.NET `dotnet publish` or `dotnet build` and package push lives. The hub default takes an explicit
hook or a build-nuget hook per output, each uploading `release-asset-<branch>-<target>`. This is where the
.NET `dotnet publish` or `dotnet build` lives, though a package push does not. The hub default takes an explicit
project path, and a project needing different build behavior replaces the hook. A data-only
repo's own output (e.g. a symbol library) is not yet
expressible as a hub hook or an `enable_*` input, so it stays a carried leaf until the hub task
grows one.
- **Package-registry pushes** (NuGet.org, PyPI): the target both builds **and** publishes to its
registry. NuGet pushes from inside the build-nuget hook (OIDC trusted publishing through
`NuGet/login`, no stored API key) *and* also uploads a `release-asset-*` (.7z) for the GitHub
release. PyPI is split: the build-pypi hook only builds and uploads the
- **Package-registry pushes** (NuGet.org, PyPI): both are split, and the push never sits in the
hook. OIDC trusted publishing validates the token's `job_workflow_ref` claim, which names the
workflow the job actually ran from, so a push from a hub-hosted task is rejected at the token
exchange, NuGet.org answering `HTTP 401` and PyPI under its own code. And because D7.2 has a callee
declare `permissions:` only where every caller grants that scope at startup, the release task's
jobs declare none and run under the calling job's whole grant, so a push anywhere inside that
task would put `id-token: write` on every job in it. The
build-nuget hook uploads a `nuget-build-<branch>` artifact for a separate `publish-nuget` job in
the caller's own `publish-release.yml`, which authenticates through `NuGet/login` and therefore
carries `id-token: write` (plus `actions: write` to delete the artifact it consumed), *and* also
uploads a `release-asset-*` (.7z) for the GitHub release. PyPI is the same shape: the build-pypi hook only builds and uploads the
`pypi-build-<branch>` artifact, and the separate `publish-pypi` job in the caller's own
`publish-release.yml` does the OIDC Trusted-Publishing upload (`id-token: write` is granted only
at that one entry point), and PyPI contributes **no** `release-asset-*`.
`publish-release.yml` does the OIDC Trusted-Publishing upload, behind an `environment: pypi`
gate and with `skip-existing: true` (`id-token: write` is granted only at that one entry
point), and PyPI contributes **no** `release-asset-*`.
- **Image-registry pushes** (Docker Hub): `build-docker-task.yml`, hub-hosted like
`build-release-task.yml`, pushes multi-arch tags directly and contributes **no**
`release-asset-*`. The image set comes from a docker-prepare hook (the hub default emits the
`build-release-task.yml`, pushes the default branch multi-arch (amd64+arm64) and any other
branch `amd64`-only, and contributes **no** `release-asset-*`. The image set comes from a docker-prepare hook (the hub default emits the
single vanilla entry an `image` input implies). A multi-image or upstream-pinned repo carries its
own hook, and a shared base layer comes from a required docker-build-base hook with no hub
default. To publish the Docker Hub repository overview, the hub-hosted `publish-docker-readme-task.yml`
Expand Down
8 changes: 4 additions & 4 deletions .agents/skills/workflow-ci-contract/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,16 @@ description: >-
- **Applicability.** A guarantee governing a construct the repo does not contain is N/A: recorded, excluded from the verdict, never a defect. A source-only pipeline is mostly N/A and that is fine.
- **Operational is binary.** Every applicable guarantee holds, or the workflow is not operational. A single applicable input-output mismatch is a defect regardless of how clean the YAML looks.
- **Reached, not carried.** A standard workflow whose job graph is identical across repos of a type is a `workflow_call` task the hub hosts once, and a repo carries only a caller stub pinned to a hub release commit plus a composite-action hook at `.github/actions/<hook>` for what is its own. A hub task reaches its own actions and sibling tasks through `$/`, which resolves at that pinned commit. The merge-bot is the first, and `docs/reusable-workflows.md` in the hub carries the model, the hook contract, and the phase each workflow migrates in. Until a workflow's phase ships, its copy is graded as below.
- **Two layers.** Orchestration (the PR entry workflow, publisher, version/release/badge jobs) is generic and standard at the job level. Build leaves (`build-<target>-task.yml`) are repo-owned. Inputs like `github`/`nuget`/`dockerhub`/`expect_release_assets` live on the orchestrator, a leaf only receives `ref`/`branch`/`smoke` and a derived `push`, so assert each input in the layer that declares it. What a repo curates is the list of targets, and adding or dropping one edits the whole surface together: the `enable_<target>` input, the `build-<target>` job and its `github-release` `needs:` entry, the `changes` paths-filter entry and output, and the `smoke-build` enable-forward (D6.4).
- **Two layers.** Orchestration (the PR entry workflow, publisher, version and release jobs) is generic and standard at the job level. Build leaves (the `build-<target>` tasks) are repo-owned. Inputs like `github`/`dockerhub`/`expect_release_assets` live on the orchestrator, a leaf receives `ref`/`branch`/`smoke` and whatever else its target needs, a derived `push` among them where that leaf pushes, so assert each input in the layer that declares it. A package target declares no push input on either layer, its push living in a separate `publish-<target>` job in the repo's own publisher. What a repo curates is the list of targets, and adding or dropping one edits the whole surface together: the `enable_<target>` input, the `build-<target>` job and its `github-release` and `build-docker` `needs:` entries, the `changes` paths-filter entry and output, the `smoke-build` enable-forward, and a package target's `publish-<target>` job (D6.4).

## Style Rules That Break in One-Line Diffs

- **Pin every action to a commit SHA** with a trailing `# vX.Y.Z` comment, first-party included. The one documented no-pin exception is `dotnet/nbgv@master`. Invent no others.
- **Names carry meaning**: `-task.yml` files and "task" names are reusable (`on: workflow_call`), entry points end in what they do and their names end in "action", every job `name:` ends in "job" and every step in "step". A ruleset-bound required check's job `name:` and the ruleset `context:` are one string renamed together, in the live ruleset and the hub's `repo-config/` payloads in lockstep, or required-check enforcement silently breaks.
- **Concurrency**: top-level workflows use `group: '${{ github.workflow }}-${{ github.ref }}'` with `cancel-in-progress: true`. The publisher is the documented exception: a global ref-independent group with `cancel-in-progress: false`, so publishes serialize and never cancel mid-push.
- **Concurrency**: top-level workflows use `group: '${{ github.workflow }}-${{ github.ref }}'` with `cancel-in-progress: true`. Two are documented exceptions. The publisher takes a global ref-independent group with `cancel-in-progress: false`, so publishes serialize and never cancel mid-push. The merge-bot takes `cancel-in-progress: false` and keys on the PR number rather than `github.ref`, per D8.1, so each PR queues independently and every event runs to completion.
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
- **Shells**: every multi-line bash `run:` starts `set -Eeuo pipefail`. Multi-line `if:` uses `>-`, never `|`.
- **Boolean inputs** are declared in both trigger blocks and compared against both forms, `${{ inputs.foo == true || inputs.foo == 'true' }}`, since `workflow_dispatch` delivers strings.
- **Permissions validate before `if:`**, so even a skipped job needs valid `permissions:`, and a callee's extra scope (`actions: write`, `id-token: write`) is granted by the caller at the one entry point that needs it.
- **Permissions validate before `if:`**, so a callee declares `permissions:` only where every caller grants that scope at startup and otherwise declares none, running under the calling job's grant. A callee's extra scope (`actions: write` for cleanup) is granted by the caller at the one entry point that needs it.
- **Chaining across optional jobs** allowlists `success`/`skipped` explicitly, because `!= 'failure'` lets `cancelled` through.
- **Docker layer cache** targets a registry tag (`buildcache-<branch>`), never `type=gha`.
- **Workflow YAML is LF.** Preserve endings on every edit.
Expand All @@ -38,7 +38,7 @@ description: >-
- **The seam contract**: a target contributes a release file by uploading `release-asset-<branch>-<target>`, and the release job collects by `pattern:` plus `merge-multiple:`, never `artifact-ids:`, canonical even for a single target. A repo with no file target passes `expect_release_assets: false` at the caller.
- **Artifacts are an intra-run handoff**: consume-then-delete at the point of consumption, gated to the consumer's condition, best-effort, `retention-days: 1` on every upload as the backstop, and never a blanket delete of the run's artifact set, which destroys the diagnostics you need when the run fails.
- **No-op republish**: an unchanged version re-pushes nothing, the release-create step skips when the tag exists, registries dedupe server-side (`--skip-duplicate`, `skip-existing: true`), and Docker alone always re-pushes by design.
- **A build failure blocks every publish target**: `github-release` needs every build, and the terminal registry pusher guards with `!failure() && !cancelled()`, so nothing partial ships.
- **A build failure blocks every publish target**: `github-release` needs every build, the terminal registry pusher guards with `!failure() && !cancelled()`, and a package target's separate `publish-<target>` job `needs:` the release-task call, so nothing partial ships.

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,13 @@ Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as input to output
- **D4.2** `target_commitish` is the built commit's SHA (NBGV `GitCommitId`), never a branch name and never `github.sha`.
- **D4.3** Every release is a tag plus source zip, README, and LICENSE, file targets attach `release-asset-*`, and a no-file-target caller passes `expect_release_assets: false` or the release-create step fails on unmatched files.
- **D4.4** No-op republish: an unchanged version re-pushes nothing, the release-create skips when the tag exists (refreshed only on `workflow_dispatch`), registries dedupe server-side, and Docker always re-pushes by design.
- **D4.5** A failed build blocks every publish target: `github-release` needs every build, the terminal registry pusher guards `!failure() && !cancelled()`, so nothing partial ships.
- **D4.5** A failed build blocks every publish target: `github-release` needs every build, the terminal registry pusher guards `!failure() && !cancelled()`, and a package target's separate `publish-<target>` job `needs:` the release-task call, so nothing partial ships.
- **D4.6** A deploy check asserts which release and which environment answer, waiting for convergence to a bounded timeout, with an unreachable host reported distinctly from an HTTP status.

## D5: Resource Cleanup

- **D5.1** A cross-job transfer artifact is deleted at its point of consumption. An in-run intermediate may rely on the retention backstop.
- **D5.2** The delete runs under the same condition as its consumer, so a no-op re-run skips the release-asset delete while the PyPI build-artifact delete still runs.
- **D5.2** The delete runs under the same condition as its consumer, so a no-op re-run skips the release-asset delete while the `nuget-build-*` and `pypi-build-*` deletes still run.
- **D5.3** Cleanup is best-effort (`continue-on-error`, tolerate a failed listing, delete all matching ids).
- **D5.4** Every `upload-artifact` sets `retention-days: 1`.
- **D5.5** Never blanket-delete the run's artifacts, which destroys diagnostics and auto-emitted build records.
Expand All @@ -49,12 +49,12 @@ Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as input to output
- **D6.1** The release job downloads by `pattern:`/`merge-multiple:`, never `artifact-ids:`, canonical for single-target repos too.
- **D6.2** Branch-derived config reads `inputs.branch`, never `github.ref_name`.
- **D6.3** Artifact names are branch-suffixed.
- **D6.4** A target add or drop updates the whole surface together: `enable_<target>` input, `build-<target>` job, `github-release` `needs:` entry, paths-filter entry and output, and the `smoke-build` enable-forward.
- **D6.4** A target add or drop updates the whole surface together: `enable_<target>` input, `build-<target>` job, its `github-release` and `build-docker` `needs:` entries, paths-filter entry and output, the `smoke-build` enable-forward, and a package target's separate `publish-<target>` job.

## D7: Concurrency, Permissions, Safety

- **D7.1** The publisher serializes: global ref-independent concurrency group, `cancel-in-progress: false`.
- **D7.2** Every reusable job declares valid `permissions:` (validated before `if:`), a callee's extra scope granted by the caller.
- **D7.2** A reusable job declares `permissions:` only where every caller grants that scope at startup (the block is validated before `if:`), and otherwise declares none and runs under the calling job's grant, a callee's extra scope granted by the caller at the one entry point needing it.
- **D7.3** Boolean inputs are declared in both trigger blocks and compared against both forms.
- **D7.4** Optional-dependency chaining allowlists `success`/`skipped` explicitly.

Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/fleet-skills/.source-digest
Original file line number Diff line number Diff line change
@@ -1 +1 @@
fdc184b741106834
e82406371aa2e203
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ publisher passes `branch: ${{ github.ref_name }}`, which the tasks forward and r
`build-release-task.yml` is a hub-hosted task with per-target `enable_*` inputs, so a repo drops a
target by setting its `enable_<target>: false` at the caller stub rather than deleting a job: the
hub task carries the full job graph for every repo, and the caller stub's `with:` block is where
the target list is expressed. A repo still curates its path-filter entry in
`test-pull-request.yml`, and (for PyPI) the `publish-pypi` job in its own `publish-release.yml`,
since `id-token: write` belongs at that one entry point. CodeGen, versioning, badge, merge-bot,
and Dependabot are target-agnostic.
the target list is expressed. A repo still curates, in `test-pull-request.yml`, its
path-filter entry, that filter's output, and the `smoke-build` enable-forward, all three together
per D6.4, since an entry nothing consumes never smoke-builds the target. And, for a package
target, the `publish-nuget` or `publish-pypi` job in its own `publish-release.yml`, since
`id-token: write` belongs at that one entry point. CodeGen, versioning, merge-bot, and Dependabot
are target-agnostic.

## Orchestration vs. build: the override seam

Expand Down Expand Up @@ -62,22 +64,30 @@ and project-path inputs its targets need.
Pick by where each artifact *goes*, not by language:

- **Files attached to the GitHub Release** (zips, binaries, packaged libraries): a dotnet-publish
hook or a build-nuget hook per output, each uploading `release-asset-<branch>-<name>`. This is where the
.NET `dotnet publish` or `dotnet build` and package push lives. The hub default takes an explicit
hook or a build-nuget hook per output, each uploading `release-asset-<branch>-<target>`. This is where the
.NET `dotnet publish` or `dotnet build` lives, though a package push does not. The hub default takes an explicit
project path, and a project needing different build behavior replaces the hook. A data-only
repo's own output (e.g. a symbol library) is not yet
expressible as a hub hook or an `enable_*` input, so it stays a carried leaf until the hub task
grows one.
- **Package-registry pushes** (NuGet.org, PyPI): the target both builds **and** publishes to its
registry. NuGet pushes from inside the build-nuget hook (OIDC trusted publishing through
`NuGet/login`, no stored API key) *and* also uploads a `release-asset-*` (.7z) for the GitHub
release. PyPI is split: the build-pypi hook only builds and uploads the
- **Package-registry pushes** (NuGet.org, PyPI): both are split, and the push never sits in the
hook. OIDC trusted publishing validates the token's `job_workflow_ref` claim, which names the
workflow the job actually ran from, so a push from a hub-hosted task is rejected at the token
exchange, NuGet.org answering `HTTP 401` and PyPI under its own code. And because D7.2 has a callee
declare `permissions:` only where every caller grants that scope at startup, the release task's
jobs declare none and run under the calling job's whole grant, so a push anywhere inside that
task would put `id-token: write` on every job in it. The
build-nuget hook uploads a `nuget-build-<branch>` artifact for a separate `publish-nuget` job in
the caller's own `publish-release.yml`, which authenticates through `NuGet/login` and therefore
carries `id-token: write` (plus `actions: write` to delete the artifact it consumed), *and* also
uploads a `release-asset-*` (.7z) for the GitHub release. PyPI is the same shape: the build-pypi hook only builds and uploads the
`pypi-build-<branch>` artifact, and the separate `publish-pypi` job in the caller's own
`publish-release.yml` does the OIDC Trusted-Publishing upload (`id-token: write` is granted only
at that one entry point), and PyPI contributes **no** `release-asset-*`.
`publish-release.yml` does the OIDC Trusted-Publishing upload, behind an `environment: pypi`
gate and with `skip-existing: true` (`id-token: write` is granted only at that one entry
point), and PyPI contributes **no** `release-asset-*`.
- **Image-registry pushes** (Docker Hub): `build-docker-task.yml`, hub-hosted like
`build-release-task.yml`, pushes multi-arch tags directly and contributes **no**
`release-asset-*`. The image set comes from a docker-prepare hook (the hub default emits the
`build-release-task.yml`, pushes the default branch multi-arch (amd64+arm64) and any other
branch `amd64`-only, and contributes **no** `release-asset-*`. The image set comes from a docker-prepare hook (the hub default emits the
single vanilla entry an `image` input implies). A multi-image or upstream-pinned repo carries its
own hook, and a shared base layer comes from a required docker-build-base hook with no hub
default. To publish the Docker Hub repository overview, the hub-hosted `publish-docker-readme-task.yml`
Expand Down
Loading