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
54 changes: 54 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ name: CI
# apt. Each has its own workflow, triggered by the files that can change it. What is left
# here is fast and is the half that catches most mistakes — the machine definition, and
# whether the scripts and Taskfiles parse at all.
#
# It does pull one of them: the published QEMU of the pinned version, seconds rather than
# minutes, so that the command line this repository builds can be handed to the binary that
# has to accept it. See the step.

# A branch is tested through its pull request, and main when something lands on it. Not
# both: an unfiltered `push:` fires alongside `pull_request:` for every push to a branch
Expand All @@ -28,6 +32,8 @@ concurrency:

permissions:
contents: read
# To read back the QEMU this commit pins, rather than build it. See the step.
packages: read

jobs:
gate:
Expand Down Expand Up @@ -62,3 +68,51 @@ jobs:
# boot first found itself with no /init.
- name: Build the tools
run: task tools

# `task test` proves the machine package agrees with itself. The steps below ask the
# QEMU binary, which is the half that can disagree: the device set is decided in
# qemu/devices.mak and the device and property names are written out by hand in
# machine/machine.go, and nothing connected the two. A dropped device, a property
# renamed at a version bump, or a typo all produced the same thing — a VM that does
# not start, found days later by whoever booted one.
#
# Here and not only in qemu.yml, which is the workflow that has a QEMU: qemu.yml
# fires on qemu/** alone, so a change to machine/machine.go — the likeliest way to
# get this wrong — never reaches it.
- name: Log in to GitHub Packages
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# Seconds, against the tens of minutes this workflow deliberately does not spend:
# the runtime image qemu.yml published for the version qemu/Dockerfile pins, unpacked
# into the same _output/ tree a build would write.
#
# continue-on-error, for one case that is not a fault: a pull request that bumps the
# pin asks for a version no one has published yet, because publishing happens from
# main. That pull request changes qemu/**, so qemu.yml builds the binary and runs the
# very same check against it — which is why this may say so and stop, and the step
# after it is skipped rather than run against some other QEMU. Any other pull failure
# skips the check too, and shows as a failed step in the run.
- name: Fetch the pinned QEMU
id: qemu
continue-on-error: true
run: task qemu:fetch

# vhost-vsock is a kernel module, and the machine's vsock device cannot be created
# without /dev/vhost-vsock. The runner has the node and gives the user no access to
# it, which is why the chmod is not decoration: without it QEMU says "Could not open
# '/dev/vhost-vsock': Permission denied" and the device goes unchecked. Loaded here rather than stood in for: there is no backend
# that keeps the device line and drops the requirement, and a runner without it is
# told what went unchecked instead of being shown a pass.
- name: Load vhost_vsock
if: steps.qemu.outcome == 'success'
run: |
sudo modprobe vhost_vsock && sudo chmod 0666 /dev/vhost-vsock ||
echo "no vhost_vsock here; the check will name vhost-vsock-pci as unchecked"

- name: Check QEMU accepts the machine's arguments
if: steps.qemu.outcome == 'success'
run: task verify:args
31 changes: 31 additions & 0 deletions .github/workflows/qemu.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ jobs:
- name: Set up Task
uses: go-task/setup-task@v2

# For the acceptance check at the end, which is a Go test. The runner's own Go is
# whatever the image happens to carry, and go.mod names the one this builds with.
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true

- name: Set up Buildx
uses: docker/setup-buildx-action@v4

Expand All @@ -70,6 +78,29 @@ jobs:
QEMU_CACHE_TO=type=gha,mode=max \
QEMU_JOBS=4

# vhost-vsock is a kernel module, and the machine's vsock device cannot be created
# without /dev/vhost-vsock. The runner has the node and gives the user no access to
# it, which is why the chmod is not decoration: without it QEMU says "Could not open
# '/dev/vhost-vsock': Permission denied" and the device goes unchecked. Loaded rather than stood in for: there is no backend that
# keeps the device line and drops the requirement, and a runner without it is told
# what went unchecked instead of being shown a pass.
- name: Load vhost_vsock
run: |
sudo modprobe vhost_vsock && sudo chmod 0666 /dev/vhost-vsock ||
echo "no vhost_vsock here; the check will name vhost-vsock-pci as unchecked"

# The other half of what a device allowlist decides. `task qemu:build` ends in
# qemu:verify, which asks this binary which accelerators and firmware files it has;
# this asks whether it accepts the command line the machine package builds, which is
# where a device removed from qemu/devices.mak actually surfaces — as a VM that does
# not start, weeks later, reading like a fault in whatever launched it.
#
# ci.yml runs the same check on every push, against the published binary. This is the
# lane that catches a change made on *this* side, before the binary it would break is
# published at all.
- name: Check the built QEMU accepts the machine's arguments
run: task verify:args

- name: Upload the extracted binaries
uses: actions/upload-artifact@v4
with:
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ task build # everything, into _output/
task shell # boot the machine and look around inside it
task lint # gofmt, vet, and whether the scripts and Taskfiles parse
task test # the machine definition
task verify:args # and whether the QEMU in _output/ accepts what it produces
task fingerprint # this machine's identity
task release # one tarball, one version
```
Expand Down Expand Up @@ -334,7 +335,12 @@ pins the toolchain, `image/build.sh` is the build, and the task runs it with

Five workflows, and the split is about cost. `ci.yml` runs on every push and builds none of
the three artefacts — it is `task lint` and `task test`, which is fast and catches most
mistakes. Each artefact has a path-triggered workflow of its own, because QEMU and the
mistakes. It also *pulls* one: `task qemu:fetch` unpacks the published QEMU of the pinned
version in seconds, and `task verify:args` hands it the command line `machine.Spec.Args`
builds. The device set is decided in `qemu/devices.mak` and the device and property names
are written out by hand in `machine/machine.go`; nothing else connects the two, and a
device dropped from the allowlist reads exactly like a typo added here — a VM that does not
start, found by whoever boots one. Each artefact has a path-triggered workflow of its own, because QEMU and the
kernel are tens of minutes each and the base image is ~1.5 GB of apt: `qemu.yml`,
`kernel.yml`, `image.yml`. Each ends in the verification that belongs to it, so a build
that lost a device, a kernel that lost its PVH notes, or an image that grew an identity
Expand Down
23 changes: 23 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,29 @@ tasks:
done
echo "OK: the shell scripts parse"

verify:args:
desc: >-
Ask the QEMU in _output/ whether it accepts the command line the machine package
builds. Every interesting Spec is started under the TCG binary, stopped before its
first instruction, and required to answer on QMP.
cmds:
# It crosses two parts — the machine definition and the binary — so it is here rather
# than in qemu/Taskfile.yml, and it refuses rather than skipping: the test itself
# skips when there is no binary, which is right for `go test ./...` in a source
# checkout and would be a gate that passes for the wrong reason here.
- |
set -euo pipefail
test -x {{.OUTPUT_ABS}}/bin/qemu-system-x86_64-tcg || {
echo "no {{.OUTPUT_ABS}}/bin/qemu-system-x86_64-tcg to ask." >&2
echo " task qemu:fetch the published build of the pinned version, seconds" >&2
echo " task qemu:build from source, tens of minutes" >&2
exit 1; }
# -count=1 because the answer depends on a file the test cache does not know about:
# a rebuilt QEMU with a device removed would otherwise be met with a cached pass.
# -v because what was rewritten for the TCG binary, and what a host without
# /dev/vhost-vsock left unchecked, is the part a reader has to see.
- SPIN_MACHINE_OUTPUT={{.OUTPUT_ABS}} go test ./machine -run TestQEMUAcceptsEveryArgument -count=1 -v

fingerprint:
desc: >-
Print this machine's identity: the hash of the QEMU binary, the kernel and the initrd
Expand Down
Loading
Loading