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",