From 974eff42bb91a1b51993c7b3fb9a2774aa95785c Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Tue, 13 Oct 2026 14:56:47 -0300 Subject: [PATCH] Back to one release workflow (SLSA Build L2); the daily OSV scan stays release.yml, dispatched on main, builds, signs, attests and publishes in one job again, on its Blacksmith runner reading main's BuildKit cache, and its SAN is release.yml@refs/heads/main: nothing but this repository's main can invoke it, so the SAN binds the repository. The reusable builder never made the release L3 - task release shared the job with the id-token - and being callable from any repository it needed a guard and a pinned source repository to stay as safe as before. osv.yml and task osv stay, now checking the SBOM's signature against release.yml@refs/heads/main. go-tools back at 99d7447. Reverts f033995 and b207bee but for the OSV scan. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/build-release.yml | 245 ---------------------------- .github/workflows/release.yml | 227 +++++++++++++++++++++++++- Taskfile.yml | 7 +- docs/releasing.md | 23 +-- go.mod | 2 +- go.sum | 4 +- workflows/workflows_test.go | 7 +- 7 files changed, 237 insertions(+), 278 deletions(-) delete mode 100644 .github/workflows/build-release.yml diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml deleted file mode 100644 index c44c76a..0000000 --- a/.github/workflows/build-release.yml +++ /dev/null @@ -1,245 +0,0 @@ -name: Build a release - -# What release.yml runs, and nothing else may: the build, its signatures and attestations, the tag -# and the publish, as a workflow that only a workflow call starts (attestgate's Builder, held in -# workflows/workflows_test.go). Every certificate this job signs with names this file, not the -# caller, so whoever takes the release pins the code that built and attested it: -# build-release.yml@refs/heads/main, with release.yml@refs/heads/main as what was dispatched. -# -# On GitHub's own runner and from cold, which is what lets that pin mean something. A Blacksmith -# runner's certificate reads self-hosted, and main's BuildKit cache is an input no pin holds: a -# layer a lane wrote is what the release would ship, with nothing in the provenance to say so. -# What it costs is time - QEMU, the kernel and the image compiled each release - and the bytes -# are the same: the builds are reproducible, and hack/fingerprint-diff says when they are not. - -on: - workflow_call: - inputs: - version: - description: "Release version, e.g. v20260909.01. Left empty, one is generated for today." - required: false - type: string - outputs: - version: - description: "The version released." - value: ${{ jobs.release.outputs.version }} - -permissions: - contents: write - # Keyless signing: cosign asks Sigstore for a short-lived certificate bound to this workflow at - # this ref. A called workflow has at most its caller's permissions, so release.yml grants it too. - id-token: write - -jobs: - release: - # Any repository may call a public reusable workflow, and the certificate its run gets names - # this file at the ref it called - build-release.yml@refs/heads/main from someone else's - # workflow, over what that repository checked out. So the builder runs here or nowhere, and a - # verifier pins the source repository besides the SAN (--certificate-github-workflow-repository). - if: github.repository == 'spin-stack/spin-machine' - runs-on: ubuntu-24.04 - # Every artefact compiled from cold on four cores: QEMU and the kernel are each tens of minutes. - timeout-minutes: 300 - outputs: - version: ${{ steps.version.outputs.version }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - # The whole history and the tags: the version below counts the tags already - # taken today, and a shallow clone would count zero and hand out .01 twice. - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version-file: go.mod - cache: true - - - name: Set up Task - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - - # The runner's toolchains this build never uses, for the disk a cold kernel, QEMU and image - # build fill: the runner starts with less free space than one kernel tree's objects take. - - name: Make room on the disk - run: | - set -euo pipefail - df -h / - sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL - df -h / - - # A BuildKit of its own, empty: the Dockerfiles' cache mounts and the Taskfile's local cache - # scopes start with nothing, so every layer is this commit's. - - name: Start an empty builder - run: docker buildx create --use --name release --driver docker-container - - # 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 - # containing a quote or a semicolon would be pasted into the script rather than - # assigned to a variable — and it is validated below, which is after that would have - # mattered. - - name: Decide the version - id: version - env: - INPUT_VERSION: ${{ inputs.version }} - run: | - set -euo pipefail - if [ -n "${INPUT_VERSION:-}" ]; then - version="$INPUT_VERSION" - origin="given by the caller" - else - day=$(date -u +%Y%m%d) - # Count what today already has. `git tag -l` on a fetch-depth:0 checkout sees - # every tag; the sequence is two digits so that v20260909.02 sorts after .01 - # and both sort before v20260910.01, as strings, everywhere. - taken=$(git tag -l "v${day}.*" | wc -l) - version=$(printf 'v%s.%02d' "$day" "$((taken + 1))") - origin="generated for today (${taken} already taken)" - fi - - case "$version" in - v[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9].[0-9][0-9]) ;; - *) echo "::error::'$version' is not vYYYYMMDD.NN" >&2; exit 1 ;; - esac - - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "Releasing $version — $origin" - - # `task release` builds all four artefacts and packs them, ending in hack/release, - # which refuses to produce a tarball with anything missing. A release that installs - # and then fails for want of an option ROM is worse than no release. - - name: Build the release - env: - VERSION: ${{ steps.version.outputs.version }} - # Nothing written back: the cache this run fills is the empty builder's, and gone with it. - CACHE_EXPORT: "false" - run: task release QEMU_JOBS=4 KERNEL_NPROC=4 - - # The machine's identity, and what it costs whoever holds checkpoints. - # - # The alternative to hack/fingerprint-diff is opening two release pages and comparing - # sixty-four hex digits by eye, which is a thing nobody does — so the answer to "why - # did every checkpoint in the fleet stop resuming?" is one nobody has. The script - # fetches the previous release's machine.env and states the consequence in a sentence, - # at the top of the notes, where a consumer deciding whether to take this release - # reads it. - # - # It exits 0 on every verdict: a changed machine is a release this repository makes on - # purpose. The point is not to prevent it but to keep it from being quiet. - - name: What this release is, and what it breaks - id: manifest - env: - GH_TOKEN: ${{ github.token }} - VERSION: ${{ steps.version.outputs.version }} - run: | - set -euo pipefail - name="spin-machine-${VERSION}-linux-x86_64" - manifest="_output/release/${name}/usr/share/spin-stack/machine.env" - cat "$manifest" - - # The release before this one, for both the verdict and the notes: the newest published - # one that is not this version, so a re-run of a version that already published compares - # against its predecessor rather than against itself. A listing that fails fails the - # step - read as "no releases", it would announce a first release that is not one. - releases=$(gh release list -R "$GITHUB_REPOSITORY" --limit 30 --json tagName -q '.[].tagName') - previous=$(printf '%s\n' "$releases" | grep -vFx -- "$VERSION" | head -1 || true) - - if [ -n "$previous" ]; then - # machine.env's lines are the script's key=value arguments, as they are. - # shellcheck disable=SC2046 - REFERENCE="$previous" hack/fingerprint-diff $(grep '_sha256=' "$manifest") > /tmp/verdict.md - # What reached the machine since: the commits by the part they changed, the pins - # that moved, the kernel options and the patches, read from git. - go run ./hack/releasenotes -from "$previous" -to HEAD > /tmp/changes.md - else - echo "Nothing to compare against: this is the first published release." > /tmp/verdict.md - echo "The first release: nothing before it to say what changed against." > /tmp/changes.md - fi - cat /tmp/verdict.md - cat /tmp/changes.md - - { - echo 'notes<> "$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, called by release.yml 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}" -builder .github/workflows/build-release.yml -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. The - # identity is this file's: github.workflow_ref is the caller's, in a called workflow too. - - name: Sign and attest what the release offers - env: - IDENTITY: ${{ github.server_url }}/${{ github.repository }}/.github/workflows/build-release.yml@${{ github.ref }} - run: | - set -euo pipefail - for f in _output/release/*.tar.gz _output/release/*.tar.gz.sha256 _output/release/machine.env _output/release/*.cdx.json; 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 \ - --certificate-github-workflow-repository spin-stack/spin-machine "${f}" - cosign verify-blob-attestation --type slsaprovenance1 --bundle "${f}.provenance.sigstore.json" \ - --certificate-identity "${IDENTITY}" \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - --certificate-github-workflow-repository spin-stack/spin-machine "${f}" - done - # The tarball's SBOM (hack/release), attested over it: what it is made of, by the - # workflow that built it. The same SBOM is inside it, at usr/share/spin-stack/sbom. - for f in _output/release/*.tar.gz; do - cosign attest-blob --yes --type cyclonedx --predicate "${f%.tar.gz}.cdx.json" \ - --bundle "${f}.cdx.sigstore.json" "${f}" > /dev/null - cosign verify-blob-attestation --type cyclonedx --bundle "${f}.cdx.sigstore.json" \ - --certificate-identity "${IDENTITY}" \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - --certificate-github-workflow-repository spin-stack/spin-machine "${f}" - done - go tool attestgate files -require provenance _output/release - go tool attestgate files -require cdx -except '*.sha256,machine.env,*.cdx.json' _output/release - - # 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 - run: | - set -euo pipefail - git tag "${{ steps.version.outputs.version }}" - git push origin "${{ steps.version.outputs.version }}" - - - name: Publish - uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 - with: - tag_name: ${{ steps.version.outputs.version }} - name: ${{ steps.version.outputs.version }} - body: ${{ steps.manifest.outputs.notes }} - # machine.env is published on its own, and not only inside the tarball, so the - # next release can read it back in a few hundred bytes and say whether it - # strands the checkpoints taken against this one. - files: | - _output/release/*.tar.gz - _output/release/*.tar.gz.sha256 - _output/release/machine.env - _output/release/*.cdx.json - _output/release/*.sigstore.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e947f13..b8b3bf0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,10 +11,9 @@ 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 calls build-release.yml, -# which names the version, builds, signs and attests - as build-release.yml@refs/heads/main, the -# identity whoever takes the release holds its files to - and only then tags the commit. This file -# publishes nothing itself. A pushed tag starts nothing. +# 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: workflow_dispatch: @@ -24,9 +23,10 @@ on: required: false type: string -# What the builder may have: a called workflow gets no more than its caller grants. 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 @@ -42,9 +42,220 @@ concurrency: jobs: release: if: github.ref == 'refs/heads/main' - uses: ./.github/workflows/build-release.yml - with: - version: ${{ inputs.version }} + 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. + timeout-minutes: 180 + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The whole history and the tags: the version below counts the tags already + # taken today, and a shallow clone would count zero and hand out .01 twice. + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache: true + + - name: Set up Task + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 + + # Its own sticky disk, and not one of the lanes'. This job builds all four artefacts, so + # it would want every lane's disk at once and can only have one — and a release can + # overlap a lane, which sharing a key would make them contend for. The BuildKit scopes it + # reads are still the lanes', so what main already compiled is not compiled again; see + # the note in qemu.yml and CACHE_BACKEND in Taskfile.yml. + # And no silent fallback, which matters most here: a release that compiled QEMU and the + # kernel from cold because its builder quietly became a local one is hours, and it would + # look like nothing but a slow release. + - name: Setup Blacksmith Builder + uses: useblacksmith/setup-docker-builder@19215110ab936351210feebdfa5b440b4493e184 # v2.2.0 + with: + cache-key: spin-machine/release + nofallback: true + + # Without this, `--cache-to type=gha` below is a silent no-op: the tokens BuildKit + # needs reach JavaScript actions and not `run:` steps. See the long note in qemu.yml. + - name: Expose the Actions cache to buildx + uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0 + + # 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 + # containing a quote or a semicolon would be pasted into the script rather than + # assigned to a variable — and it is validated below, which is after that would have + # mattered. + - name: Decide the version + id: version + env: + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if [ -n "${INPUT_VERSION:-}" ]; then + version="$INPUT_VERSION" + origin="given by the caller" + else + day=$(date -u +%Y%m%d) + # Count what today already has. `git tag -l` on a fetch-depth:0 checkout sees + # every tag; the sequence is two digits so that v20260909.02 sorts after .01 + # and both sort before v20260910.01, as strings, everywhere. + taken=$(git tag -l "v${day}.*" | wc -l) + version=$(printf 'v%s.%02d' "$day" "$((taken + 1))") + origin="generated for today (${taken} already taken)" + fi + + case "$version" in + v[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9].[0-9][0-9]) ;; + *) echo "::error::'$version' is not vYYYYMMDD.NN" >&2; exit 1 ;; + esac + + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Releasing $version — $origin" + + # `task release` builds all four artefacts and packs them, ending in hack/release, + # which refuses to produce a tarball with anything missing. A release that installs + # and then fails for want of an option ROM is worse than no release. + - name: Build the release + env: + VERSION: ${{ steps.version.outputs.version }} + # The same scopes the per-artefact workflows write on main, decided in + # Taskfile.yml. That is the whole point of them being named there: a release + # reads what the lane on main already paid to compile, instead of starting cold + # because a workflow file spelled the scope differently. + CACHE_BACKEND: gha + # Reads what main's lanes exported and writes nothing back: a release is built once, and + # the next one reads main's newer cache, not this. See CACHE_EXPORT in Taskfile.yml. + CACHE_EXPORT: "false" + run: task release QEMU_JOBS=4 KERNEL_NPROC=4 + + # The machine's identity, and what it costs whoever holds checkpoints. + # + # The alternative to hack/fingerprint-diff is opening two release pages and comparing + # sixty-four hex digits by eye, which is a thing nobody does — so the answer to "why + # did every checkpoint in the fleet stop resuming?" is one nobody has. The script + # fetches the previous release's machine.env and states the consequence in a sentence, + # at the top of the notes, where a consumer deciding whether to take this release + # reads it. + # + # It exits 0 on every verdict: a changed machine is a release this repository makes on + # purpose. The point is not to prevent it but to keep it from being quiet. + - name: What this release is, and what it breaks + id: manifest + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + name="spin-machine-${VERSION}-linux-x86_64" + manifest="_output/release/${name}/usr/share/spin-stack/machine.env" + cat "$manifest" + + # The release before this one, for both the verdict and the notes: the newest published + # one that is not this version, so a re-run of a version that already published compares + # against its predecessor rather than against itself. A listing that fails fails the + # step - read as "no releases", it would announce a first release that is not one. + releases=$(gh release list -R "$GITHUB_REPOSITORY" --limit 30 --json tagName -q '.[].tagName') + previous=$(printf '%s\n' "$releases" | grep -vFx -- "$VERSION" | head -1 || true) + + if [ -n "$previous" ]; then + # machine.env's lines are the script's key=value arguments, as they are. + # shellcheck disable=SC2046 + REFERENCE="$previous" hack/fingerprint-diff $(grep '_sha256=' "$manifest") > /tmp/verdict.md + # What reached the machine since: the commits by the part they changed, the pins + # that moved, the kernel options and the patches, read from git. + go run ./hack/releasenotes -from "$previous" -to HEAD > /tmp/changes.md + else + echo "Nothing to compare against: this is the first published release." > /tmp/verdict.md + echo "The first release: nothing before it to say what changed against." > /tmp/changes.md + fi + cat /tmp/verdict.md + cat /tmp/changes.md + + { + echo 'notes<> "$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 _output/release/*.cdx.json; 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 + # The tarball's SBOM (hack/release), attested over it: what it is made of, by the + # workflow that built it. The same SBOM is inside it, at usr/share/spin-stack/sbom. + for f in _output/release/*.tar.gz; do + cosign attest-blob --yes --type cyclonedx --predicate "${f%.tar.gz}.cdx.json" \ + --bundle "${f}.cdx.sigstore.json" "${f}" > /dev/null + cosign verify-blob-attestation --type cyclonedx --bundle "${f}.cdx.sigstore.json" \ + --certificate-identity "${IDENTITY}" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com "${f}" + done + go tool attestgate files -require provenance _output/release + go tool attestgate files -require cdx -except '*.sha256,machine.env,*.cdx.json' _output/release + + # 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 + run: | + set -euo pipefail + git tag "${{ steps.version.outputs.version }}" + git push origin "${{ steps.version.outputs.version }}" + + - name: Publish + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 + with: + tag_name: ${{ steps.version.outputs.version }} + name: ${{ steps.version.outputs.version }} + body: ${{ steps.manifest.outputs.notes }} + # machine.env is published on its own, and not only inside the tarball, so the + # next release can read it back in a few hundred bytes and say whether it + # strands the checkpoints taken against this one. + files: | + _output/release/*.tar.gz + _output/release/*.tar.gz.sha256 + _output/release/machine.env + _output/release/*.cdx.json + _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 5ceca30..efdd380 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -289,7 +289,7 @@ tasks: osv: desc: >- - What OSV knows against the newest release's SBOM, once its signature is the builder's: one + What OSV knows against the newest release's SBOM, once its signature is the release's: one line a finding, id, package@version and summary, in _output/osv.tsv. Needs gh, cosign and the network; osv.yml runs it daily and opens an issue for a finding it has not opened before. # The newest release, because it is what anything takes next; an older one still pinned is @@ -302,9 +302,8 @@ tasks: sbom="spin-machine-${tag}-linux-x86_64.cdx.json" gh release download "$tag" -p "$sbom" -p "$sbom.sigstore.json" -D "$dir" cosign verify-blob --bundle "$dir/$sbom.sigstore.json" \ - --certificate-identity https://github.com/spin-stack/spin-machine/.github/workflows/build-release.yml@refs/heads/main \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - --certificate-github-workflow-repository spin-stack/spin-machine "$dir/$sbom" + --certificate-identity https://github.com/spin-stack/spin-machine/.github/workflows/release.yml@refs/heads/main \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com "$dir/$sbom" # osv-scanner exits 1 when it found something, which is the answer; above 1 it failed. status=0 go run "github.com/google/osv-scanner/v2/cmd/osv-scanner@$(go tool versions version osv-scanner)" \ diff --git a/docs/releasing.md b/docs/releasing.md index 785f24c..3567806 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -25,7 +25,7 @@ combination of the machine's features, booted from the published tarball to a lo ## Releases -`release.yml`, through `build-release.yml`, builds all three and packs one tarball. Versions are **CalVer**, +`release.yml` builds all three and packs one tarball. Versions are **CalVer**, `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 @@ -39,21 +39,16 @@ or changed, and the patches added, changed or removed), whether checkpoints carr 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`: the builder, the -dispatched workflow at its commit, and every pin in `versions.yaml`). `release.yml` publishes -nothing itself: it calls `build-release.yml`, a workflow only a call starts, which builds from cold -on GitHub's own runner, signs, attests and publishes - so the certificate names the builder, and -that is the identity whoever takes a file checks both bundles against. With the repository it ran -in: any repository can call a public reusable workflow, and its run's certificate names this -builder too, so the SAN alone is not this repository's (the builder's job also refuses to run -anywhere else). +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/build-release.yml@refs/heads/main -pin=(--certificate-identity "$id" --certificate-oidc-issuer https://token.actions.githubusercontent.com - --certificate-github-workflow-repository spin-stack/spin-machine) -cosign verify-blob --bundle f.sigstore.json "${pin[@]}" f -cosign verify-blob-attestation --type slsaprovenance1 --bundle f.provenance.sigstore.json "${pin[@]}" f +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 ``` Every release ran on `refs/heads/main`, and that is the ref its certificate names; which release diff --git a/go.mod b/go.mod index 935d685..b06a304 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ tool ( ) require ( - github.com/spin-stack/go-tools v0.0.0-20261013172709-defd29ba99c4 + github.com/spin-stack/go-tools v0.0.0-20261013164036-99d744796066 golang.org/x/sys v0.48.0 ) diff --git a/go.sum b/go.sum index f3159da..2a062e0 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,8 @@ github.com/in-toto/attestation v1.2.0 h1:aPRUZ3azbqD7yEBD5fP3TD8Dszf+YHo284SOcpa github.com/in-toto/attestation v1.2.0/go.mod h1:r79G45gOmzPismgObLSL+rZTFxUgZLOQJI6LofTZgXk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/spin-stack/go-tools v0.0.0-20261013172709-defd29ba99c4 h1:0w4kv6lPpa80XPxqPuEYjcSSzc4pgYLtrLtcDsrPX5c= -github.com/spin-stack/go-tools v0.0.0-20261013172709-defd29ba99c4/go.mod h1:cy1ojfIbEjrfZi0lGzE+wjueaWw1jrotHQ+vyFz9pZQ= +github.com/spin-stack/go-tools v0.0.0-20261013164036-99d744796066 h1:T4uAeYrQ9e9Fml3PcZGzgji5sIAoebXK9zy94u5IBwg= +github.com/spin-stack/go-tools v0.0.0-20261013164036-99d744796066/go.mod h1:cy1ojfIbEjrfZi0lGzE+wjueaWw1jrotHQ+vyFz9pZQ= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/terminalstatic/go-xsd-validate v0.1.8 h1:UVrTCy1j3DhwaYTTUF+QYO/Nan13S0tf+Jwi+p45Bf0= diff --git a/workflows/workflows_test.go b/workflows/workflows_test.go index 30d4de4..a1084a7 100644 --- a/workflows/workflows_test.go +++ b/workflows/workflows_test.go @@ -10,11 +10,10 @@ import ( "github.com/spin-stack/go-tools/attestgate" ) -// Every step that publishes is in build-release.yml, which only release.yml from main calls, and -// 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. +// 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, MainOnly: []string{"release.yml"}, Builder: "build-release.yml", Repository: "spin-stack/spin-machine", 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",