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
21 changes: 10 additions & 11 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ 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.

on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
version:
Expand All @@ -40,6 +41,7 @@ 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.
Expand Down Expand Up @@ -82,8 +84,7 @@ jobs:
- name: Expose the Actions cache to buildx
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0

# Three sources, in order: what the caller typed, the tag that triggered this, or a
# new one for today. The generated form is deliberately not `date +%s` or a commit
# 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
Expand All @@ -99,9 +100,6 @@ jobs:
if [ -n "${INPUT_VERSION:-}" ]; then
version="$INPUT_VERSION"
origin="given by the caller"
elif [ "${{ github.ref_type }}" = "tag" ]; then
version="${{ github.ref_name }}"
origin="the tag that triggered this run"
else
day=$(date -u +%Y%m%d)
# Count what today already has. `git tag -l` on a fetch-depth:0 checkout sees
Expand Down Expand Up @@ -234,10 +232,10 @@ jobs:
go tool attestgate files -require provenance _output/release
go tool attestgate files -require cdx -except '*.sha256,machine.env,*.cdx.json' _output/release

# Tagged here when the run generated its own version, so that the next run's count
# of "tags already taken today" is right and the tarball has a commit behind it.
# 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
if: github.ref_type != 'tag'
run: |
set -euo pipefail
git tag "${{ steps.version.outputs.version }}"
Expand All @@ -264,6 +262,7 @@ jobs:
# of the notes. report.yml says why it is a job of its own, on its own runner.
report:
needs: release
if: github.ref == 'refs/heads/main'
uses: ./.github/workflows/report.yml
with:
version: ${{ needs.release.outputs.version }}
Expand Down
11 changes: 7 additions & 4 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ combination of the machine's features, booted from the published tarball to a lo
`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
number anybody chose. Pushing a `v*` tag releases that version; running the workflow by
hand with no input generates the next sequence for today, tags the commit, and writes the
number anybody chose. A release is the workflow dispatched on main, and nothing else - a pushed
tag starts nothing: given a version it makes that one, with no input it generates the next
sequence for today; either way it tags the commit once its files are signed, and writes the
notes: what changed since the release before (`hack/releasenotes`: the commits that reached
the machine by the part they changed, the pins that moved, the kernel options turned on, off
or changed, and the patches added, changed or removed), whether checkpoints carry over
Expand All @@ -43,14 +44,16 @@ its commit, and every pin in `versions.yaml`). Whoever takes one checks both aga
`release.yml` at the ref it ran on:

```sh
id=https://github.com/spin-stack/spin-machine/.github/workflows/release.yml@refs/tags/<version>
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
```

A run started by hand ran on `refs/heads/main`, and that is the ref its certificate names.
Every release ran on `refs/heads/main`, and that is the ref its certificate names; which release
a file is, its provenance says (`buildDefinition.externalParameters.version`), and whoever takes it
holds it to the version they asked for.

What the release is made of is its CycloneDX SBOM, written by `hack/release` from what the builds
recorded - the apk databases QEMU and e2fsprogs were statically linked in (each build's
Expand Down
2 changes: 1 addition & 1 deletion workflows/workflows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
// 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, 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",
Expand Down