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
115 changes: 115 additions & 0 deletions .github/workflows/compat.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
name: Compat

# Whether a checkpoint the newest release saved resumes on this change's machine, and whether the
# fingerprint still accounts for every ROM the machine maps (boot:compat, boot:roms): after every
# change on main to what a resumed guest is loaded into - the machine's definition, QEMU, the
# kernel, the firmware and their pins - and before every release.
#
# On the KVM runner, because it boots: the newest release is unpacked as the machine before, and
# the machine here is that tree with this change's QEMU, firmware and kernel built over it - the
# base image is the release's, since a checkpoint resumes with the disks it was saved with. The
# builds read and write a BuildKit cache that outlives the run (checkout keeps .cache/), so a
# change that leaves QEMU and the kernel alone rebuilds neither.
#
# The test holds the promise only where it is made: when the two fingerprints differ the contract
# moved on purpose, the test says so and passes, and the release notes say it for the fleet.
#
# Not on a pull request: the KVM runner's job hook refuses that event, since a workflow asking
# for its runners would run a pull request's code as root there. It runs on main after a merge
# that touches the machine, on a dispatch - a branch tried before its pull request merges - and
# as a job release.yml waits for, so no release is published that a checkpoint of the one
# before it does not resume on.

on:
push:
branches: [main]
paths:
- machine/**
- cmd/spin-machine/**
- qemu/**
- kernel/**
- boot/compat_test.go
- boot/roms_test.go
- versions.yaml
- .github/workflows/compat.yml
workflow_dispatch:
workflow_call:

permissions:
contents: read

# Per ref and never cancelled: a run on main is a commit somebody needs the answer for, and a
# release's waits on it. Called from release.yml it is the release's job and takes its group.
concurrency:
group: compat-${{ github.workflow }}-${{ github.ref_name }}
cancel-in-progress: false

jobs:
compat:
runs-on: [self-hosted, linux, x64, kvm]
timeout-minutes: 120
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# .cache/buildkit is the runner's QEMU and kernel cache; a clean checkout would empty it.
clean: false

- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: false

- name: Set up Task
uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0

- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2

# A builder that can export its cache to the directory above; the runner keeps it.
- name: A BuildKit that keeps its cache
run: docker buildx inspect compat > /dev/null 2>&1 && docker buildx use compat || docker buildx create --use --name compat --driver docker-container

- name: Build this change's QEMU and kernel, and the tools
run: task qemu:build kernel:build tools

# The newest release, checked against its sha256 and its signature, as the machine before;
# this change's machine is the same tree with what was just built in place of its own.
- name: The machine before, and this one
run: |
set -euo pipefail
tag=$(gh release view -R "$GITHUB_REPOSITORY" --json tagName -q .tagName)
name="spin-machine-${tag}-linux-x86_64"
dir=_output/compat/$tag
if [ ! -f "$dir/$name/usr/share/spin-stack/machine.env" ]; then
rm -rf "$dir" && mkdir -p "$dir"
gh release download "$tag" -R "$GITHUB_REPOSITORY" -D "$dir" \
-p "$name.tar.gz" -p "$name.tar.gz.sha256" -p "$name.tar.gz.sigstore.json"
(cd "$dir" && sha256sum -c "$name.tar.gz.sha256")
cosign verify-blob --bundle "$dir/$name.tar.gz.sigstore.json" \
--certificate-identity "https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release.yml@refs/heads/main" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com "$dir/$name.tar.gz"
tar -xzf "$dir/$name.tar.gz" -C "$dir"
fi
previous=$PWD/$dir/$name/usr/share/spin-stack
here=$PWD/_output/compat/here
rm -rf "$here" && cp -as "$previous" "$here"
rm -rf "$here/bin" "$here/qemu" "$here/kernel"
mkdir -p "$here/bin" "$here/kernel"
cp _output/bin/qemu-system-x86_64 _output/bin/qemu-img _output/bin/spin-machine "$here/bin/"
cp -r _output/qemu "$here/qemu"
cp _output/kernel/vmlinux "$here/kernel/vmlinux"
{
echo "SPIN_COMPAT_PREVIOUS=$previous"
echo "SPIN_MACHINE_OUTPUT=$here"
echo "Comparing against $tag" >&2
} >> "$GITHUB_ENV"

- name: Every ROM the machine maps is the fingerprint's
run: SPIN_ROM_TEST=1 go test ./boot/ -run '^TestEveryROMIsTheFingerprints$' -count=1 -v -timeout 3m

- name: A checkpoint of the release before resumes here
run: go test ./boot/ -run '^TestACheckpointOfTheReleaseBeforeResumesHere$' -count=1 -v -timeout 10m
38 changes: 13 additions & 25 deletions .github/workflows/kernel.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@ name: Kernel
# The guest kernel, for the same reason QEMU has its own workflow: compiling it is tens of
# minutes and the merge gate must not wait for it.
#
# It matters more than a build time, though. A changed kernel is a changed machine — the
# fingerprint hashes this binary by content — so every checkpoint taken against the previous
# one stops resuming. The job prints the checksum of what it produced for exactly that
# reason: a config change whose effect nobody looked at is a fleet-wide event nobody
# noticed.
# It matters more than a build time, though: a changed kernel is every guest's next boot. It
# does not strand checkpoints - a resumed guest runs the kernel in its saved memory, and the
# fingerprint is the machine's contract, not this binary - but the job prints the checksum of
# what it produced, so a config change is something somebody looked at.

on:
# kernel/**, and versions.yaml when it moves the kernel or its toolchain; see the note in
Expand Down Expand Up @@ -101,28 +100,17 @@ jobs:
run: |
CACHE_EXPORT=${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} CACHE_BACKEND=gha task kernel:build KERNEL_NPROC=4

# Compared against the last released machine, which every release publishes as a
# machine.env asset. The kernel's checksum is one of the numbers in it that decide
# whether a checkpoint still resumes (hack/fingerprint-diff names them).
#
# So a pull request that changes the config, or bumps the Debian toolchain the kernel
# is compiled with, says on its own summary that it strands every checkpoint in
# existence. That is not a reason to reject it — it is a release this repository makes
# deliberately, and the step exits 0 either way. It is a reason to know: the change
# that does this need not look like much, and a comment edited in this directory's
# Dockerfile is enough.
#
# It speaks only for the kernel: this workflow builds no QEMU, and the verdict says so
# rather than implying it checked the whole machine.
- name: What this kernel is, and what it breaks
env:
GH_TOKEN: ${{ github.token }}
# What it produced, on the run's summary: a pull request that changes the config, or
# bumps the Debian toolchain the kernel is compiled with, changes every guest's next
# boot, and the change that does it need not look like much. Checkpoints are not
# affected: the kernel is not in the fingerprint (machine.Spec.Fingerprint).
- name: What this kernel is
run: |
set -euo pipefail
kernel=$(sha256sum _output/kernel/vmlinux | cut -d' ' -f1)
echo "kernel sha256: ${kernel}"
echo "config sha256: $(sha256sum _output/kernel/kernel-config | cut -d' ' -f1)"
hack/fingerprint-diff "kernel_sha256=${kernel}" | tee -a "$GITHUB_STEP_SUMMARY"
{
echo "kernel sha256: $(sha256sum _output/kernel/vmlinux | cut -d' ' -f1)"
echo "config sha256: $(sha256sum _output/kernel/kernel-config | cut -d' ' -f1)"
} | tee -a "$GITHUB_STEP_SUMMARY"

- name: Upload the kernel
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Expand Down
27 changes: 11 additions & 16 deletions .github/workflows/qemu.yml
Original file line number Diff line number Diff line change
Expand Up @@ -166,24 +166,19 @@ jobs:
- name: Check the built QEMU accepts the machine's arguments
run: task verify:args

# The other thing a changed QEMU binary is: a different machine. It is hashed by
# content into the fingerprint exactly as the kernel is, so a configure flag added
# here, or a device removed from devices.mak, stops every checkpoint in the fleet from
# resuming. Compared against the last released machine.env and reported; never a
# failure, since changing the machine is a thing that gets done on purpose.
#
# The TCG binary is deliberately not compared. It is not part of a machine's identity
# and must never run a machine a KVM guest will later restore from.
- name: What this QEMU is, and what it breaks
env:
GH_TOKEN: ${{ github.token }}
# What it produced, and whether it still offers the machine type every checkpoint is
# saved on, on the run's summary. A changed QEMU binary is a new emulator of the same
# machine (machine.MachineType stays): it is not in the fingerprint, and whether a
# checkpoint the released QEMU saved resumes on it is boot:compat's to say, on the KVM
# runner (compat.yml).
- name: What this QEMU is
run: |
set -euo pipefail
qemu=$(sha256sum _output/bin/qemu-system-x86_64 | cut -d' ' -f1)
echo "qemu sha256: ${qemu}"
hack/fingerprint-diff "qemu_sha256=${qemu}" \
"bios_sha256=$(sha256sum _output/qemu/qboot.bin | cut -d' ' -f1)" \
"pvh_sha256=$(sha256sum _output/qemu/pvh.bin | cut -d' ' -f1)" | tee -a "$GITHUB_STEP_SUMMARY"
{
echo "qemu sha256: $(sha256sum _output/bin/qemu-system-x86_64 | cut -d' ' -f1)"
echo "qboot.bin: $(stat -c%s _output/qemu/qboot.bin) bytes"
go run ./hack/machinetype check -qemu _output/bin/qemu-system-x86_64
} | tee -a "$GITHUB_STEP_SUMMARY"

- name: Upload the extracted binaries
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,15 @@ concurrency:
cancel-in-progress: false

jobs:
# A checkpoint of the release before resumes on this one, when the two have one fingerprint,
# and the fingerprint accounts for every ROM the machine maps: on the KVM runner, before
# anything is built here to publish (compat.yml says why).
compat:
if: github.ref == 'refs/heads/main'
uses: ./.github/workflows/compat.yml

release:
needs: compat
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
Expand Down Expand Up @@ -174,6 +182,10 @@ jobs:
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
# Whether the QEMU shipped offers a machine type newer than the one every checkpoint is
# saved on: said, never acted on (machine.MachineType).
offer=$(go run ./hack/machinetype check -qemu "_output/release/${name}/usr/share/spin-stack/bin/qemu-system-x86_64")
if [ -n "$offer" ]; then printf '\n%s\n' "$offer" >> /tmp/verdict.md; fi
cat /tmp/verdict.md
cat /tmp/changes.md

Expand Down
23 changes: 14 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,20 @@ These are release invariants, not defaults. Everything below them is a strong en
default unless it says otherwise; an experiment may depart from a default when it is isolated
and makes its scope and result clear.

1. **A release is one machine.** `machine.Spec.Fingerprint` hashes the QEMU binary, the
kernel, the initrd and the firmware the guest runs (the BIOS, qboot.bin, and pvh.bin) by
content, together with the five arguments that decide the machine's shape. A checkpoint
resumes only onto a machine with the fingerprint of the one it was saved on; across two
that differ a resume is undefined rather than an error. A content change to any of those
files is a fleet-wide event: every checkpoint taken against the previous machine stops
resuming. That is the design, not a bug to work around. A source or recipe change is a possible fleet-wide event; whether
it is one is decided by the resulting artifact hashes and shape, not by the diff's apparent
size. Verify them before promotion.
1. **A checkpoint resumes on any release with its fingerprint, and the fingerprint is the
machine's contract.** `machine.Spec.Fingerprint` hashes the versioned machine type
(`machine.MachineType`), the shape, the chipset's globals and every device as the command
line gives it (`Spec.Contract`), the BIOS's size, and the host CPU under `-cpu host`. Not
the QEMU binary, the kernel, the initrd or pvh.bin: QEMU promises a newer release loads what
an older one of the same versioned type saved, and a resumed guest runs the kernel in its
saved memory. `task boot:compat` holds every change to that promise - a checkpoint the newest
release saved must resume on this tree when the fingerprint is the same - and `task
boot:roms` holds the fingerprint to every ROM a booted machine maps. Across two fingerprints
a resume is undefined rather than an error, so a change to anything the fingerprint hashes -
the machine type, a device or one of its properties, the BIOS's size - is a fleet-wide event:
every checkpoint taken against the previous machine stops resuming. That is the design, made
on purpose and never as a side effect: a QEMU bump keeps the machine type, and moving it is
its own change.

2. **The base image is never written to.** Every VM maps it read-only through a qcow2
backing chain and many share one file. Anything that opens it for writing invalidates
Expand Down
23 changes: 12 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,17 @@ A virtual machine: QEMU, the guest kernel, the base image, and the definition of
machine they make.

The four are one thing, and the reason is not tidiness. A VM resumed from a checkpoint
loads device and CPU state into a machine that has to be the same shape as the one the
loads device and CPU state into a machine that has to be the same hardware as the one the
checkpoint was saved on, and nothing checks that at run time. So the machine's identity
is computed from the things that decide its shape — `machine.Spec.Fingerprint` hashes the
QEMU binary, the kernel, the initrd and the firmware the guest runs (the BIOS, qboot.bin and pvh.bin)
*by content*, together with the five arguments that decide what a guest sees. Two machines
with the same fingerprint can take each other's checkpoints. Two with different
fingerprints cannot, and a release in which any of those files moved has a different
fingerprint by construction.
is its contract — `machine.Spec.Fingerprint` hashes the versioned machine type
(`machine.MachineType`, `pc-q35-11.1`), the shape, every device as the command line gives it,
and the BIOS's size. Two machines with the same fingerprint can take each other's checkpoints,
and two with different fingerprints cannot. A new QEMU of the same machine type, a new kernel or
new firmware of the same size is the same machine: QEMU promises a newer release loads what an
older one of the same machine type saved, and `task boot:compat` holds every release to it.

One repository, one version, one machine a checkpoint resumes onto.
One repository, one version per release, and checkpoints that resume across releases until the
machine itself changes.

**What is deliberately not in a release: software that owns a guest.** This repository
builds a machine, and a release is not bootable on its own by design — whoever runs guests
Expand All @@ -42,7 +43,7 @@ One tarball:
| `qemu/patches/`, `qemu/qboot-*.patch`, `qemu/qboot-COPYING` | the changes this tree makes to QEMU and qboot, shipped with the binaries built from them |
| `kernel/vmlinux` | plus `kernel-config` |
| `image/rootfs.qcow2` | read-only, 0444 |
| `machine.env` | the version, the commit it was built from, and the checksums that decide whether a checkpoint resumes |
| `machine.env` | the version, the commit it was built from, each file's checksum, and `fingerprint_sha256`, the contract that decides whether a checkpoint resumes |
| `SOURCES` | every upstream source by version, URL and SHA-256, and the written offer |
| `packages.txt` | every package and exact version in the base image |

Expand All @@ -53,8 +54,8 @@ either. What a release has beyond a build directory is what `hack/release` creat
copies when it names one: `machine.env` and `SOURCES` it writes, and the QEMU and qboot
patches under `qemu/` it takes from this repository, not from the build. A tree without
a manifest is still a release for every purpose except being named — the fingerprint
decides whether checkpoints resume, and it is computed from the files, not from
`machine.env`. `_output/bin/` holds one thing more: `spin-machine`,
decides whether checkpoints resume, and it is computed from the machine's definition and its
BIOS, not from `machine.env`. `_output/bin/` holds one thing more: `spin-machine`,
which `task tools` builds for working in this repository and `hack/release` does not ship —
a release is the machine, not the tool that boots it. There is one layout: nothing rearranges
the files on the way out of a build, into a tarball or into a consumer. Let the three differ
Expand Down
Loading