diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml new file mode 100644 index 0000000..d5dee00 --- /dev/null +++ b/.github/workflows/build-release.yml @@ -0,0 +1,237 @@ +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: + 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 "${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 diff --git a/.github/workflows/osv.yml b/.github/workflows/osv.yml new file mode 100644 index 0000000..2b7adce --- /dev/null +++ b/.github/workflows/osv.yml @@ -0,0 +1,59 @@ +name: OSV + +# Every day, what OSV knows against the newest release's SBOM (task osv), and an issue for each +# finding nobody has been told of yet. A release's libraries are a commit's, and a vulnerability is +# published after the commit: nothing in a build can find it, only asking again later can. + +on: + schedule: + - cron: "17 5 * * *" + workflow_dispatch: + +permissions: + contents: read + issues: write + +concurrency: + group: osv + cancel-in-progress: false + +jobs: + scan: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - 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 + + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Scan the newest release + env: + GH_TOKEN: ${{ github.token }} + run: task osv + + # One issue a finding, by its title, ever: one closed - fixed, or judged not to matter - is + # not opened again, and the same finding in the next release is the same issue. + - name: Open an issue for each new finding + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + known=$(gh issue list --state all --limit 1000 --json title -q '.[].title') + while IFS=$'\t' read -r id pkg summary; do + title="osv: ${id} in ${pkg}" + if printf '%s\n' "$known" | grep -Fxq -- "$title"; then + continue + fi + gh issue create --title "$title" --body "$(printf '%s\n\n%s is in the newest release'"'"'s SBOM (task osv). https://osv.dev/vulnerability/%s' "$summary" "$pkg" "$id")" + done < _output/osv.tsv diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b8b3bf0..e947f13 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,9 +11,10 @@ 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. +# 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. on: workflow_dispatch: @@ -23,10 +24,9 @@ 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,220 +42,9 @@ 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. - 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 + uses: ./.github/workflows/build-release.yml + with: + version: ${{ inputs.version }} # 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 e9063d1..ada8e44 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -287,6 +287,33 @@ tasks: # Release # ========================================================================== + osv: + desc: >- + What OSV knows against the newest release's SBOM, once its signature is the builder'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 + # its consumer's to scan, which spin's control plane does for what it was installed with. + cmds: + - | + set -euo pipefail + tag=$(gh release view --json tagName -q .tagName) + dir=$(mktemp -d); trap 'rm -rf "$dir"' EXIT + 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 "$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)" \ + scan source -L "$dir/$sbom" --format json > "$dir/osv.json" || status=$? + [ "$status" -le 1 ] || exit "$status" + mkdir -p _output + jq -r '.results[]?.packages[]? | .package as $p | .vulnerabilities[]? | [.id, ($p.name + "@" + $p.version), (.summary // "")] | @tsv' \ + "$dir/osv.json" | sort -u > _output/osv.tsv + echo "$tag: $(wc -l < _output/osv.tsv) finding(s), in _output/osv.tsv" + release: desc: Build everything and pack one versioned tarball. cmds: diff --git a/docs/releasing.md b/docs/releasing.md index 3567806..cf91bdb 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` builds all three and packs one tarball. Versions are **CalVer**, +`release.yml`, through `build-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,12 +39,14 @@ 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`: 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: +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: ```sh -id=https://github.com/spin-stack/spin-machine/.github/workflows/release.yml@refs/heads/main +id=https://github.com/spin-stack/spin-machine/.github/workflows/build-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 \ diff --git a/go.mod b/go.mod index b06a304..f6d492e 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ tool ( ) require ( - github.com/spin-stack/go-tools v0.0.0-20261013164036-99d744796066 + github.com/spin-stack/go-tools v0.0.0-20261013171334-a38a073cfc2d golang.org/x/sys v0.48.0 ) diff --git a/go.sum b/go.sum index 2a062e0..e569f11 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-20261013164036-99d744796066 h1:T4uAeYrQ9e9Fml3PcZGzgji5sIAoebXK9zy94u5IBwg= -github.com/spin-stack/go-tools v0.0.0-20261013164036-99d744796066/go.mod h1:cy1ojfIbEjrfZi0lGzE+wjueaWw1jrotHQ+vyFz9pZQ= +github.com/spin-stack/go-tools v0.0.0-20261013171334-a38a073cfc2d h1:4xHpFO/xiIXTnln50gS42x3zLGYGIlXhaePU3NTIFiA= +github.com/spin-stack/go-tools v0.0.0-20261013171334-a38a073cfc2d/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/versions.yaml b/versions.yaml index 64ac242..a6cd106 100644 --- a/versions.yaml +++ b/versions.yaml @@ -197,3 +197,10 @@ version: v1.7.12 track: github-release https://github.com/rhysd/actionlint note: task lint:workflows, the only thing that reads .github/ before GitHub does. spin's version. + +- name: osv-scanner + kind: module + source: github.com/google/osv-scanner/v2/cmd/osv-scanner + version: v2.7.0 + track: github-release https://github.com/google/osv-scanner + note: task osv, which osv.yml runs daily over the newest release's SBOM. A bump is checked with task osv. diff --git a/workflows/workflows_test.go b/workflows/workflows_test.go index a1084a7..6278826 100644 --- a/workflows/workflows_test.go +++ b/workflows/workflows_test.go @@ -10,10 +10,11 @@ import ( "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. +// 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. func TestEveryUploadIsAttested(t *testing.T) { - g := attestgate.Gate{Root: "..", RequireSBOM: true, MainOnly: []string{"release.yml"}, NotPublished: map[string]string{ + g := attestgate.Gate{Root: "..", RequireSBOM: true, MainOnly: []string{"release.yml"}, Builder: "build-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",