Attested Delivery, End to End
Attested Delivery, End to End
Section titled “Attested Delivery, End to End”How a change in this repository travels from a pull request to a signed, independently verifiable release — and exactly which gate signs what.
Diátaxis mode: Explanation, with one embedded How-to section (“How to Adopt This in Your Own Project”, numbered steps + a verification command). Reference lookups (per-workflow triggers, verify commands) are not duplicated here — they live in
CI-WORKFLOWS.mdandSIGNED-RELEASES.md. This document explains how the pieces compose.
This repository is a Mode-A consumer of the central reusable-workflow
repository zircote/.github: it does not re-implement signing, scanning, or
verification — it calls central reusables pinned to a full commit SHA, and
each gate’s verdict normalizes on SARIF.
The Two Seams
Section titled “The Two Seams”Attested delivery has two distinct points where evidence is produced and bound to a subject. Keeping them separate is the key to understanding the whole system.
| Seam | When | Subject | Evidence form | Where it lands |
|---|---|---|---|---|
| Merge-time gates | every push / PR to main | the source tree / commit | SARIF | the repo’s code-scanning hub (Security tab); the Code scanning results required check is the merge gate |
| Deploy-time attestation | tag push (release) / main push (container) | a published artifact bound by digest | signed in-toto attestation (Sigstore keyless) | GitHub artifact attestations, verifiable with gh attestation verify |
The same four central reusables power the merge-time seam. A subset of them re-runs at deploy time, where the SARIF they emit is no longer just uploaded — it becomes a signed predicate bound to a release subject.
Stage 1 — Merge-Time Quality Gates (quality-gates.yml)
Section titled “Stage 1 — Merge-Time Quality Gates (quality-gates.yml)”quality-gates.yml is a thin caller of four zircote/.github central
reusables. It runs on push and PR to main, on a weekly Monday 06:00 UTC
schedule, and on manual dispatch. Top-level permissions are contents: read;
each job widens scope only as its reusable requires.
| Job | Reusable (pinned SHA) | Emits | SARIF → code-scanning category |
|---|---|---|---|
sast | reusable-sast-codeql.yml@740cb8efb57af0187f88e9b4f939355b871a5895 | CodeQL (Rust, build-mode: none) | /language:rust |
sca | reusable-sca-osv.yml@77a87549a65c6c978a0e87efe0168ed3517f7ca4 | OSV-Scanner (--lockfile=Cargo.lock, fail-on-severity: high) + dependency-review (PR gate) | OSV SARIF; artifact OSV Scanner SARIF file |
posture | reusable-scorecard.yml@77a87549a65c6c978a0e87efe0168ed3517f7ca4 | OpenSSF Scorecard (push/schedule only — needs the default branch) | scorecard |
trivy | reusable-trivy.yml@77a87549a65c6c978a0e87efe0168ed3517f7ca4 | Trivy filesystem scan (scan-iac: true: Dockerfile, manifests, licenses) | trivy-iac-license |
Why a caller must over-grant permissions. A reusable workflow’s job
permissions are a floor: a caller must grant ≥ every permission any of
the reusable’s jobs declares, or GitHub fails the call at startup — even for
jobs that are conditionally skipped. That is why sast and trivy grant
packages: read (the CodeQL analyze job and the Trivy image job declare it)
even though no package is read in template state, and why sca grants
pull-requests: write (its dependency-review job declares it).
What each reusable outputs (the attestation seam). Every gate reusable
publishes its SARIF as a named artifact and exposes sarif-artifact /
sarif-filename outputs. At merge time those outputs are unused (the SARIF is
uploaded to code scanning via github/codeql-action/upload-sarif). At release
time they become the input to the signing seam — see Stage 3.
Stage 2 — The publish = false Template Gate
Section titled “Stage 2 — The publish = false Template Gate”This template ships publishing-disabled. Cargo.toml carries:
publish = falseEvery release-side workflow resolves this at runtime via
cargo metadata --no-deps --locked --format-version 1, mapping
.packages[0].publish == [] to publishable = "false". That boolean gates the
three external channels. Deleting the publish = false line arms them at
once.
A GitHub Release is deliberately not gated by this switch — it is a tag
primitive, not an external publish. A pushed tag always produces an attested
GitHub Release (binaries + SBOM + source snapshot), in both template and armed
state, provided the fail-closed verify job passes.
What publish = false disables (external channels only):
| Channel | Mechanism in template state |
|---|---|
| Container build → sign → verify | pipeline.yml’s gate job resolves publishable=false; the docker job’s if: requires publishable == 'true', so docker, docker-sign, docker-verify, gate-image, and attest-container-scan are all skipped — the template builds no image. |
| crates.io publish | publish.yml’s guard job sets publishable=false; the publish job’s if: gates the whole job off. (cargo itself also refuses cargo publish while publish = false.) |
| Homebrew tap update | package-homebrew.yml reads publishable from Cargo.toml at the released tag; every formula-push step is gated on publishable == 'true'. |
What publish = false does NOT gate:
| Always runs on a tag | Mechanism |
|---|---|
| GitHub Release | release.yml’s release job is gated on startsWith(github.ref, 'refs/tags/') alone. It runs the full build → attest → SBOM → fail-closed verify chain and then creates the GitHub Release with the attested binaries, SBOM, and source snapshot — regardless of publishable. |
The deploy-time evidence chain always executes — binaries build, provenance
and SBOM are attested, verification runs fail-closed, and the GitHub Release is
created. What publish = false withholds is external distribution (crates.io,
container registry, Homebrew), so the template can ship attested GitHub Releases
without claiming a crates.io name or pushing an image.
Stage 3 — Build → Sign → Verify
Section titled “Stage 3 — Build → Sign → Verify”There are two independent deploy-time chains: release binaries (release.yml)
and the container image (pipeline.yml). Both are fail-closed.
3a. Release binaries, SBOM, and the gate-verdict seam (release.yml)
Section titled “3a. Release binaries, SBOM, and the gate-verdict seam (release.yml)”Triggered by a v*.*.* tag (a workflow_dispatch from any branch is a
dry-run: version suffixed -dev, release job skipped). Flow:
-
meta— resolvebin,version,publishablefromcargo metadata. -
build(5-platform matrix:linux-amd64,linux-arm64,macos-arm64,macos-amd64via cross-target,windows-amd64) — each binary is staged as{bin}-{version}-{platform}and gets SLSA build provenance attached at build time viaactions/attest-build-provenance(@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32, v4.1.0). -
source—git archiveproduces a published source snapshot{bin}-{version}-source.tar.gzand attests its provenance. Because the exact bytes ship as a release asset, the gate attestations below can be verified from any workstation against a real, downloadable subject. -
sbom— a CycloneDX SBOM (anchore/sbom-action) is generated over the binaries and bound to every binary viaactions/attest-sbom(@c604332985a26aa8cf1bdc465b92731239ec6b9e, v4.1.0). -
The gate-verdict seam — this is the bridge from Stage 1. Two of the four merge-time reusables re-run here over the shipped source:
gate-sca→reusable-sca-osv.yml@77a87549a65c6c978a0e87efe0168ed3517f7ca4gate-trivy→reusable-trivy.yml@77a87549a65c6c978a0e87efe0168ed3517f7ca4
Their SARIF outputs are then fed to the central signing reusable
reusable-attest-scan.yml@740cb8efb57af0187f88e9b4f939355b871a5895, which signs each verdict as an in-toto attestation bound to the source snapshot digest:Attest job predicate-type predicate from attest-scahttps://zircote.github.io/attestations/sca/v1gate-sca.outputs.sarif-{artifact,filename}attest-iac-licensehttps://zircote.github.io/attestations/iac-license/v1gate-trivy.outputs.sarif-{artifact,filename}SAST (CodeQL) and posture (Scorecard) are not re-run here — they are repo/source-level and already enforced at merge in
quality-gates.yml. -
verify(fail-closed, before the release exists) — downloads the{bin}-{version}-*artifacts, asserts exactly 7 are present (5 binaries- SBOM document + source tarball; a partial set must never ship), then for each:
- platform binary → verify provenance + SBOM (
--predicate-type https://cyclonedx.org/bom); - source tarball → verify provenance + both gate verdicts, asserting the
signer with
--signer-workflow zircote/.github/.github/workflows/reusable-attest-scan.ymland the predicate-type URIs above.
-
release(tag-gated —publishableis not required) — attaches binaries, the SBOM, the source snapshot, and{bin}-{version}-checksums.txtwith auto-generated notes. A pushed tag always produces this GitHub Release.testandaudit(cargo-audit) areneedsof this job because a tag is untrusted input — it is not guaranteed to point at a CI-green commit.
A tag therefore publishes nothing unattested and nothing unverified.
3b. Container image (pipeline.yml, dormant in template state)
Section titled “3b. Container image (pipeline.yml, dormant in template state)”Gated on publishable == 'true' (skipped while publish = false). On a
non-PR push/tag:
docker→release-docker.ymlbuildslinux/amd64,linux/arm64and pushes toghcr.io/{owner}/{repo}, outputting the manifestimage-digest.docker-sign→ centralsign-and-attest.yml@740cb8efb57af0187f88e9b4f939355b871a5895. Under SLSA Build L3 the signing identity is the central workflow, not this repo — the isolation boundary is what makes the provenance non-falsifiable. It attaches a Cosign signature, SLSA provenance, an SBOM, and a Grype vuln report as OCI referrers.docker-verify→ centralverify-attestation.yml@e8f0dbde068cc0701e443e7b8d57ae9917de7da3, a fail-closed gate. It verifies againstattestation-repo(this repo, where the build ran) while the Fulcio certificate identity defaults to the central signer — so verification asserts both where the build ran and who signed.gate-image→attest-container-scan— a parallel container-vuln seam:reusable-trivy.ymlscans the image by digest (artifactcontainer-scan-sarif) andreusable-attest-scan.ymlsigns the verdict under predicate-typehttps://zircote.github.io/attestations/container-scan/v1, bound to the image digest.
Container verification commands (including the mandatory
--signer-workflowfor centrally-signed images) live inSIGNED-RELEASES.md§ Container Image Attestations. They are not repeated here.
Stage 4 — crates.io Trusted Publishing (publish.yml)
Section titled “Stage 4 — crates.io Trusted Publishing (publish.yml)”Triggered by a v*.*.* tag (dispatch = dry-run). Gated on publishable.
- A pre-publish gauntlet runs
cargo fmt --check,clippy -D warnings,test,doc,cargo deny check,cargo package, andcargo publish --dry-run. It also asserts the tag version matchesCargo.toml— a mismatch fails before the irreversible publish. - Trusted Publishing (OIDC) —
rust-lang/crates-io-auth-action(@c6f97d42243bad5fab37ca0427f495c86d5b1a18, v1.0.4) mints a short-lived token from the workflow’s OIDC identity. There is no long-lived registry token secret. This requires thepublishjob to run in thecopilotenvironment withid-token: write. - Registry
.crateattestation — after publish, the workflow downloads the.cratethe registry actually serves fromstatic.crates.io, byte-compares its SHA-256 against the locally packaged archive (cargo packaging is deterministic for a commit; a mismatch fails loudly), then attaches SLSA provenance to the registry bytes viaactions/attest-build-provenance. The attestation covers what users download, not a local rebuild.
Stage 5 — Homebrew Propagation (package-homebrew.yml)
Section titled “Stage 5 — Homebrew Propagation (package-homebrew.yml)”The release in Stage 3 is authored by github-actions[bot], and bot-authored
release events do not trigger other workflows. So Homebrew is driven by a
workflow_run trigger on Release completion (with a release: published
trigger and manual dispatch as alternates).
The job only proceeds for a successful tag run
(workflow_run.conclusion == 'success' and head_branch starts with v),
resolves bin/description/license/publishable from Cargo.toml
at the released tag, computes the source tarball SHA-256 (failing loudly on
any download error), and generates a source formula
(class {CamelCase} < Formula, cargo install-based, with a --version
smoke test). It pushes to {owner}/{HOMEBREW_TAP_REPO|homebrew-tap} using
HOMEBREW_TAP_TOKEN (a PAT scoped to the tap repo — this workflow only ever
reads the source repo). The push is idempotent: a re-fire for the same
version that produces no diff is a no-op.
How to Adopt This in Your Own Project
Section titled “How to Adopt This in Your Own Project”Diátaxis mode: How-to. Numbered, task-oriented, with a verification command at the end.
-
Arm the external channels. Delete this one line from
Cargo.toml:publish = falseThis single deletion arms the container chain, crates.io publish, and Homebrew — all three resolve it from
cargo metadata. (GitHub Releases already happen on every tag; this line never gated them.) -
Set crate identity. Edit
Cargo.tomlname,version,description,license,repository, and the[[bin]]name. Every release workflow is var-driven from this metadata plus the GitHub context — no workflow file is renamed. -
Configure crates.io Trusted Publishing (one-time, per crate). On crates.io → your crate → Settings → Trusted Publishing → Add:
- Repository:
{owner}/{repo} - Workflow filename:
publish.yml - Environment:
copilot
No registry token is stored anywhere; the OIDC exchange replaces it.
- Repository:
-
Set Homebrew secrets/variables (only if shipping a tap).
- Secret
HOMEBREW_TAP_TOKEN— a PAT with write access to your tap repo (also used byrelease.ymlso the release event can propagate;workflow_runis the fallback either way). - Variable
HOMEBREW_TAP_REPO— optional; defaults tohomebrew-tapunder your owner.
Set both at Settings → Secrets and variables → Actions.
- Secret
-
(Optional) Other secrets.
CODECOV_TOKEN(coverage upload),GITLEAKS_LICENSE(secrets scan). Neither is required for attested delivery. See the Required Secrets Summary.Note: no
DEPLOY_DOCSvariable exists in any attested-delivery workflow in this repository; documentation deployment is configured separately indocs-deploy.ymland is out of scope for delivery. -
Keep the SHA-pinning convention. Every
uses:in this repository — third-party actions and thezircote/.githubcentral reusables — is pinned to a full 40-character commit SHA, never a tag or branch. Thepin-checkrequired check (centralpin-check.yml@740cb8efb57af0187f88e9b4f939355b871a5895) fails the build if any reference is not SHA-pinned. Let Dependabot (github-actions ecosystem) bump these pins; do not hand-edit them to a moving ref. -
Verify. Cut a dry-run before a real tag — dispatch Release and Publish to crates.io from any branch; both exercise the full build → attest → verify chain and tag-gate only the publish step:
Terminal window gh workflow run release.ymlgh workflow run publish.ymlgh run watch "$(gh run list --workflow=release.yml --limit=1 --json databaseId -q '.[0].databaseId')"A green dry-run with the
Verify Attestationsjob passing confirms the pipeline is wired correctly before you publish anything irreversible.
See Also
Section titled “See Also”SIGNED-RELEASES.md— verification commands, SLSA levels, keyless Sigstore, “who signs what”, troubleshooting.CI-WORKFLOWS.md— per-workflow reference: triggers, inputs, secrets, the full pipeline dependency chain.SECURITY.md§ Verifying Release Artifacts — canonical copy-paste verify commands.