From bae7c2d945782a4aa1cf55923aca74ad4918d9dd Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Tue, 13 Oct 2026 09:42:34 -0300 Subject: [PATCH] release: sign and attest the tarball, its .sha256 and machine.env S1 of the supply-chain plan. Each file the release offers is signed and attested with its SLSA provenance (go tool provenance: this workflow at its commit, every versions.yaml pin), each bundle verified file by file against the identity that made it, and attestgate files checks the directory right before the upload. workflows_test holds every workflow to attesting before it publishes; the steps that upload only what a person reads, or what ci.yml pulls, are named with why. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/release.yml | 35 +++++++++++++++++++++++++++++++++++ Taskfile.yml | 2 +- docs/releasing.md | 17 +++++++++++++++++ go.mod | 4 +++- go.sum | 8 ++++++-- workflows/workflows_test.go | 28 ++++++++++++++++++++++++++++ 6 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 workflows/workflows_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 675da59..e6dc9be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,9 @@ on: permissions: contents: write + # Keyless signing: cosign asks Sigstore for a short-lived certificate bound to this workflow at + # this ref, which is what a machine that takes the release holds its files to. + id-token: write # One release at a time, across every ref, and not per-ref. Per-ref serialises two runs on # the same tag and nothing else: a workflow_dispatch on main and a tag push are different @@ -190,6 +193,37 @@ jobs: echo RELEASE_NOTES_EOF } >> "$GITHUB_OUTPUT" + # v4 is the one that installs cosign v3. Dependabot moves this pin like every other `uses:`. + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + # Who built each file and from what - this workflow at this commit, every pin in + # versions.yaml - as SLSA provenance, beside the signature that says who made it. The + # predicate has no clock in it, so it is the same for every file of the run. + - name: Write the provenance + env: + VERSION: ${{ steps.version.outputs.version }} + run: go tool provenance -version "${VERSION}" -out _output/provenance.json + + # Each file is signed and attested, then each bundle checked against the identity that made + # it, file by file: a bundle nothing verifies is a step that can quietly stop working. + - name: Sign and attest what the release offers + env: + IDENTITY: ${{ github.server_url }}/${{ github.repository }}/.github/workflows/release.yml@${{ github.ref }} + run: | + set -euo pipefail + for f in _output/release/*.tar.gz _output/release/*.tar.gz.sha256 _output/release/machine.env; do + cosign sign-blob --yes --bundle "${f}.sigstore.json" "${f}" > /dev/null + cosign attest-blob --yes --type slsaprovenance1 --predicate _output/provenance.json \ + --bundle "${f}.provenance.sigstore.json" "${f}" > /dev/null + cosign verify-blob --bundle "${f}.sigstore.json" --certificate-identity "${IDENTITY}" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com "${f}" + cosign verify-blob-attestation --type slsaprovenance1 --bundle "${f}.provenance.sigstore.json" \ + --certificate-identity "${IDENTITY}" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com "${f}" + done + go tool attestgate files -require provenance _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. - name: Tag the commit @@ -212,6 +246,7 @@ jobs: _output/release/*.tar.gz _output/release/*.tar.gz.sha256 _output/release/machine.env + _output/release/*.sigstore.json # What every combination of the machine's features costs, booted from the tarball just # published, as report.json beside it, and what moved against the release before it at the end diff --git a/Taskfile.yml b/Taskfile.yml index c321b71..b63b6d2 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -183,7 +183,7 @@ tasks: cmds: - | set -euo pipefail - unformatted=$(gofmt -l machine cmd boot versions) + unformatted=$(gofmt -l machine cmd boot versions workflows) if [ -n "$unformatted" ]; then echo "not gofmt'd:"; echo "$unformatted"; exit 1 fi diff --git a/docs/releasing.md b/docs/releasing.md index 94dabbd..baa5899 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -36,6 +36,23 @@ the machine by the part they changed, the pins that moved, the kernel options tu or changed, and the patches added, changed or removed), whether checkpoints carry over (`hack/fingerprint-diff`), and the checksums. +Each file the release offers - the tarball, its `.sha256` and `machine.env` - carries two +Sigstore bundles beside it: `.sigstore.json`, the signature of the workflow that built it, +and `.provenance.sigstore.json`, its SLSA provenance (`go tool provenance`: this workflow at +its commit, and every pin in `versions.yaml`). Whoever takes one checks both against +`release.yml` at the ref it ran on: + +```sh +id=https://github.com/spin-stack/spin-machine/.github/workflows/release.yml@refs/tags/ +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. +`workflows/workflows_test.go` holds every workflow to attesting before it publishes. + ## The feature matrix A release also says what it costs. After it publishes, the `report` job boots the tarball it diff --git a/go.mod b/go.mod index 3a71fb3..106a7c1 100644 --- a/go.mod +++ b/go.mod @@ -3,15 +3,17 @@ module github.com/spin-stack/spin-machine go 1.27 tool ( + github.com/spin-stack/go-tools/cmd/attestgate github.com/spin-stack/go-tools/cmd/ctxlife github.com/spin-stack/go-tools/cmd/mutate + github.com/spin-stack/go-tools/cmd/provenance github.com/spin-stack/go-tools/cmd/refs github.com/spin-stack/go-tools/cmd/testquality github.com/spin-stack/go-tools/cmd/versions ) require ( - github.com/spin-stack/go-tools v0.0.0-20260930221820-8ea51671c2d8 + github.com/spin-stack/go-tools v0.0.0-20261013124106-428d61cfb405 golang.org/x/sys v0.48.0 ) diff --git a/go.sum b/go.sum index fd3cd47..e6ec9b8 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,9 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/spin-stack/go-tools v0.0.0-20260930221820-8ea51671c2d8 h1:eTsFFY8DXs90nMg/tdZH+8C0SkCOixAQQdnUrNxU0A4= -github.com/spin-stack/go-tools v0.0.0-20260930221820-8ea51671c2d8/go.mod h1:pQNBiVAi7uoXVlw5UniOQkZ/KYT9dRBeQz2KCW4XtAg= +github.com/in-toto/attestation v1.2.0 h1:aPRUZ3azbqD7yEBD5fP3TD8Dszf+YHo284SOcpahjQk= +github.com/in-toto/attestation v1.2.0/go.mod h1:r79G45gOmzPismgObLSL+rZTFxUgZLOQJI6LofTZgXk= +github.com/spin-stack/go-tools v0.0.0-20261013124106-428d61cfb405 h1:NwRkSsLhvJIZx8eiqCnNTuSxOxldtBvNOXxKG0FeNwo= +github.com/spin-stack/go-tools v0.0.0-20261013124106-428d61cfb405/go.mod h1:GgzYh3wr8jXxFMripzZZEkTmS8n8hYZm+u37cEQSbfo= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= @@ -14,3 +16,5 @@ golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/workflows/workflows_test.go b/workflows/workflows_test.go new file mode 100644 index 0000000..93a0255 --- /dev/null +++ b/workflows/workflows_test.go @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package workflows holds this repository's workflows to go-tools' attestgate: what a release +// publishes is attested with its provenance first, in the same job. +package workflows_test + +import ( + "testing" + + "github.com/spin-stack/go-tools/attestgate" +) + +// 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: "..", 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", + "qemu.yml/build/Upload the extracted binaries": "a lane's QEMU for a person to fetch; a release builds its own", + "qemu.yml/build/Publish the runtime image": "what ci.yml's argument check pulls (task qemu:fetch); a release compiles QEMU, it never takes this", + "report.yml/report/Keep the report": "the run's copy of report.json, for a person", + "report.yml/report/Publish the report, and what moved": "report.json is measurement data: nothing installs or verifies it", + }} + if err := g.Check(); err != nil { + t.Error(err) + } +}