Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 237 additions & 0 deletions .github/workflows/build-release.yml
Original file line number Diff line number Diff line change
@@ -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<<RELEASE_NOTES_EOF'
cat /tmp/changes.md
echo
cat /tmp/verdict.md
echo
echo 'The machine as it stood on this date.'
echo
echo '```'
cat "$manifest"
echo '```'
echo RELEASE_NOTES_EOF
} >> "$GITHUB_OUTPUT"

# v4 is the one that installs cosign v3. Dependabot moves this pin like every other `uses:`.
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2

# Who built each file and from what - this workflow, 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
59 changes: 59 additions & 0 deletions .github/workflows/osv.yml
Original file line number Diff line number Diff line change
@@ -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
Loading