From 1a3d3d4a8bd66580dc8901714fe8223aae4131c8 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Tue, 13 Oct 2026 11:19:19 -0300 Subject: [PATCH] release: from main only, tagged after its files are signed S4 of the supply-chain plan. The release workflow no longer runs on a pushed tag: it is dispatched on main, every job of it runs only there, it names its version from the caller or today's sequence, and tags the commit once its files are signed and attested, refusing a version tagged already. The certificate then always names release.yml at main; the provenance carries which release a file is. workflows_test holds release.yml to it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/release.yml | 21 ++++++++++----------- docs/releasing.md | 11 +++++++---- workflows/workflows_test.go | 2 +- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c045310..b8b3bf0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,11 +10,12 @@ name: Release # # The `.01` is the run within the day, because two releases on one day is normal when a # flag is being chased. +# +# A release is made from main and nothing else: dispatched there, it names its version, builds, +# signs and attests as release.yml@refs/heads/main - the one identity whoever takes the release +# holds its files to - and only then tags the commit. A pushed tag starts nothing. on: - push: - tags: - - "v*" workflow_dispatch: inputs: version: @@ -40,6 +41,7 @@ concurrency: jobs: release: + if: github.ref == 'refs/heads/main' runs-on: blacksmith-8vcpu-ubuntu-2404 # A warm run reads main's BuildKit cache and takes a few minutes (2m13s, 2026-09-30). The # ceiling is for the cold one, which compiles QEMU and the kernel, each tens of minutes. @@ -82,8 +84,7 @@ jobs: - name: Expose the Actions cache to buildx uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0 - # Three sources, in order: what the caller typed, the tag that triggered this, or a - # new one for today. The generated form is deliberately not `date +%s` or a commit + # Two sources, in order: what the caller typed, or a new one for today. The generated form is deliberately not `date +%s` or a commit # hash — it has to be readable, sortable, and the same shape as one typed by hand. # The caller's input arrives through the environment and not through ${{ }} in the # script body. Interpolation happens before the shell sees the line, so a version @@ -99,9 +100,6 @@ jobs: if [ -n "${INPUT_VERSION:-}" ]; then version="$INPUT_VERSION" origin="given by the caller" - elif [ "${{ github.ref_type }}" = "tag" ]; then - version="${{ github.ref_name }}" - origin="the tag that triggered this run" else day=$(date -u +%Y%m%d) # Count what today already has. `git tag -l` on a fetch-depth:0 checkout sees @@ -234,10 +232,10 @@ jobs: go tool attestgate files -require provenance _output/release go tool attestgate files -require cdx -except '*.sha256,machine.env,*.cdx.json' _output/release - # Tagged here when the run generated its own version, so that the next run's count - # of "tags already taken today" is right and the tarball has a commit behind it. + # Tagged here, after the files are signed and before they are published, so that the next + # run's count of "tags already taken today" is right and the tarball has a commit behind it. + # A version tagged already is refused here: git push does not move a tag. - name: Tag the commit - if: github.ref_type != 'tag' run: | set -euo pipefail git tag "${{ steps.version.outputs.version }}" @@ -264,6 +262,7 @@ jobs: # of the notes. report.yml says why it is a job of its own, on its own runner. report: needs: release + if: github.ref == 'refs/heads/main' uses: ./.github/workflows/report.yml with: version: ${{ needs.release.outputs.version }} diff --git a/docs/releasing.md b/docs/releasing.md index bd5e693..ae4596a 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -29,8 +29,9 @@ combination of the machine's features, booted from the published tarball to a lo `v20260909.01`: a release of this repository is the machine as it stood on a date. There is no API here to promise compatibility about, and the one thing a version could promise — that checkpoints still resume — is decided by the fingerprint of the artefacts, not by a -number anybody chose. Pushing a `v*` tag releases that version; running the workflow by -hand with no input generates the next sequence for today, tags the commit, and writes the +number anybody chose. A release is the workflow dispatched on main, and nothing else - a pushed +tag starts nothing: given a version it makes that one, with no input it generates the next +sequence for today; either way it tags the commit once its files are signed, and writes the notes: what changed since the release before (`hack/releasenotes`: the commits that reached the machine by the part they changed, the pins that moved, the kernel options turned on, off or changed, and the patches added, changed or removed), whether checkpoints carry over @@ -43,14 +44,16 @@ its commit, and every pin in `versions.yaml`). Whoever takes one checks both aga `release.yml` at the ref it ran on: ```sh -id=https://github.com/spin-stack/spin-machine/.github/workflows/release.yml@refs/tags/ +id=https://github.com/spin-stack/spin-machine/.github/workflows/release.yml@refs/heads/main cosign verify-blob --bundle f.sigstore.json --certificate-identity "$id" \ --certificate-oidc-issuer https://token.actions.githubusercontent.com f cosign verify-blob-attestation --type slsaprovenance1 --bundle f.provenance.sigstore.json \ --certificate-identity "$id" --certificate-oidc-issuer https://token.actions.githubusercontent.com f ``` -A run started by hand ran on `refs/heads/main`, and that is the ref its certificate names. +Every release ran on `refs/heads/main`, and that is the ref its certificate names; which release +a file is, its provenance says (`buildDefinition.externalParameters.version`), and whoever takes it +holds it to the version they asked for. What the release is made of is its CycloneDX SBOM, written by `hack/release` from what the builds recorded - the apk databases QEMU and e2fsprogs were statically linked in (each build's diff --git a/workflows/workflows_test.go b/workflows/workflows_test.go index d84e44c..a1084a7 100644 --- a/workflows/workflows_test.go +++ b/workflows/workflows_test.go @@ -13,7 +13,7 @@ import ( // Every step that publishes runs after an unconditional provenance attestation in its job; the // ones that look like an upload and ship nothing anyone installs are named here, each with why. func TestEveryUploadIsAttested(t *testing.T) { - g := attestgate.Gate{Root: "..", RequireSBOM: true, NotPublished: map[string]string{ + g := attestgate.Gate{Root: "..", RequireSBOM: true, MainOnly: []string{"release.yml"}, NotPublished: map[string]string{ "ci.yml/gate/Upload Go coverage HTML": "a coverage page for a person, kept seven days", "kernel.yml/build/Upload the kernel": "a lane's kernel for a person to fetch; a release builds its own", "lab.yml/lab/Keep the log": "a measurement's log, for a person",