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) + } +}