Repository navigation
Expand file tree
/
Copy pathTaskfile.yml
More file actions
413 lines (379 loc) · 20.7 KB
/
Copy pathTaskfile.yml
File metadata and controls
413 lines (379 loc) · 20.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
# spin-machine — a virtual machine: QEMU, the guest kernel, the base image, and the
# definition of the machine they make.
#
# The four are one thing. `machine.Spec.Fingerprint` hashes by content the binaries and the
# firmware a guest runs, together with the machine's shape, so a release in which any of them
# moved has a different fingerprint by construction. That is why they ship as one tarball with
# one version: shipped separately, they could drift apart and leave the last one to notice.
#
# This file holds the vars every part reads and the targets that cross all of them, in the
# order a change travels: build, verify, release, then trying the machine and keeping the
# pins. Each part's own targets live beside what they build — qemu/, kernel/, e2fsprogs/,
# image/ — and the probes that measure a boot in boot/, each namespaced, so `task qemu:build`
# is next to qemu/Dockerfile. `task --list` is the catalogue.
#
# What is deliberately NOT here: the software that runs inside a guest. This repository
# builds a machine and knows nothing about what boots on it, and there is no exception to
# that. A debug initramfs is the one that keeps being proposed: it is a second init, doing
# what the init a consumer already brings does, and the machine boots straight into
# /sbin/init without one.
version: '3'
silent: true
output: prefixed
includes:
qemu:
taskfile: qemu/Taskfile.yml
# The build context is the repository root: the Dockerfiles COPY qemu/devices.mak and
# image/mkosi.extra by their path from here, so every part sees the same tree and no
# `..` appears in a COPY.
dir: '{{.ROOT_DIR}}'
kernel:
taskfile: kernel/Taskfile.yml
dir: '{{.ROOT_DIR}}'
e2fsprogs:
taskfile: e2fsprogs/Taskfile.yml
dir: '{{.ROOT_DIR}}'
image:
taskfile: image/Taskfile.yml
dir: '{{.ROOT_DIR}}'
boot:
taskfile: boot/Taskfile.yml
dir: '{{.ROOT_DIR}}'
vars:
ROOT_DIR:
sh: pwd
OUTPUT_DIR: '{{.OUTPUT_DIR | default "_output"}}'
# The same directory, absolute, resolved once.
#
# Everything handed to `docker -v`, to a VM, or to a checksum has to be an absolute path,
# and `{{.OUTPUT_ABS}}` is only one when OUTPUT_DIR is relative. With
# OUTPUT_DIR=/tmp/build it produced <repository>/tmp/build, so QEMU and the kernel were
# extracted to /tmp/build and the base image was written somewhere else entirely — a
# build that succeeds and leaves half its output where nothing looks for it.
#
# -m so an output directory that does not exist yet still resolves.
OUTPUT_ABS:
sh: realpath -m "{{.OUTPUT_DIR}}"
# The version pins are NOT here. They are in versions.yaml, and each part's Taskfile hands
# the Dockerfile the entries it builds from (`{{.VERSIONS}} args <name>...`): a pin in this
# file would put it in every workflow's path filter, and a comment fixed here would rebuild
# QEMU, the kernel and the base image. What is here does not change an artefact.
#
# go-tools' command, at the version go.mod pins (its tool directive): the tool is only ever
# run from a checkout, and Go's build cache makes the second run of it as fast as a binary
# would be.
VERSIONS: go tool versions
# How many jobs to compile with. CI sets them to its runner's cores.
QEMU_JOBS: '{{.QEMU_JOBS | default 8}}'
KERNEL_NPROC: '{{.KERNEL_NPROC | default 8}}'
# The one architecture this machine is built for; every build is --platform linux/amd64.
KERNEL_ARCH: x86_64
# Where BuildKit reads and writes its cache: a directory under the repository for a
# developer, GitHub's cache service in CI. One name to switch — `CACHE_BACKEND=gha` — and
# every scope below is decided here, next to the builds they belong to, rather than as
# cache strings pasted into workflow files.
#
# The scopes are why this is a var and not a string in a workflow. `type=gha` defaults to
# the scope `buildkit`, and Docker's own documentation says what that means for a
# repository with more than one build: "each build will overwrite the cache of the
# previous, leaving only the final cache." The names have to be distinct, and they have to
# be the *same* names in the per-artefact workflows and in the release, or a release warms
# nothing that the lane on main already paid for.
#
# `gha` needs one thing this file cannot give it — see the note in
# .github/workflows/qemu.yml. The variables that authorise a write to the cache service
# are not in a plain `run:` step's environment, and without them `--cache-to type=gha` is
# accepted, stores nothing, and says nothing.
CACHE_BACKEND: '{{.CACHE_BACKEND | default "local"}}'
# Whether a build writes its cache back, and only CI's main needs it to. On Blacksmith the
# builder keeps BuildKit's own state on a sticky disk per cache-key (setup-docker-builder),
# so a lane re-reads what it built without any export; what the Actions cache adds is the
# one thing a disk cannot: the release, which builds everything with a builder of its own,
# reusing what main's lanes paid for. Exporting cost 88 s of "preparing build cache for
# export" on a kernel build (2026-09-30), on every PR and dispatch that warmed nothing a
# release reads. So the lanes export on a push to main, and nothing else does.
CACHE_EXPORT: '{{.CACHE_EXPORT | default "true"}}'
BUILDKIT_CACHE_DIR: '{{.ROOT_DIR}}/.cache/buildkit'
QEMU_CACHE_FROM: '{{if eq .CACHE_BACKEND "gha"}}type=gha,scope=qemu{{else}}type=local,src={{.BUILDKIT_CACHE_DIR}}/qemu{{end}}'
QEMU_CACHE_TO: '{{if ne .CACHE_EXPORT "false"}}--cache-to {{if eq .CACHE_BACKEND "gha"}}type=gha,scope=qemu,mode=max{{else}}type=local,dest={{.BUILDKIT_CACHE_DIR}}/qemu,mode=max,compression=zstd{{end}}{{end}}'
KERNEL_CACHE_FROM: '{{if eq .CACHE_BACKEND "gha"}}type=gha,scope=kernel{{else}}type=local,src={{.BUILDKIT_CACHE_DIR}}/kernel{{end}}'
KERNEL_CACHE_TO: '{{if ne .CACHE_EXPORT "false"}}--cache-to {{if eq .CACHE_BACKEND "gha"}}type=gha,scope=kernel,mode=max{{else}}type=local,dest={{.BUILDKIT_CACHE_DIR}}/kernel,mode=max,compression=zstd{{end}}{{end}}'
E2FSPROGS_CACHE_FROM: '{{if eq .CACHE_BACKEND "gha"}}type=gha,scope=e2fsprogs{{else}}type=local,src={{.BUILDKIT_CACHE_DIR}}/e2fsprogs{{end}}'
E2FSPROGS_CACHE_TO: '{{if ne .CACHE_EXPORT "false"}}--cache-to {{if eq .CACHE_BACKEND "gha"}}type=gha,scope=e2fsprogs,mode=max{{else}}type=local,dest={{.BUILDKIT_CACHE_DIR}}/e2fsprogs,mode=max,compression=zstd{{end}}{{end}}'
# The container that builds the base image. Small — mkosi, qemu-utils — and cached like
# the rest: uncached, every CI run reinstalls it from the archive before any image work
# starts.
IMAGE_CACHE_FROM: '{{if eq .CACHE_BACKEND "gha"}}type=gha,scope=image{{else}}type=local,src={{.BUILDKIT_CACHE_DIR}}/image{{end}}'
IMAGE_CACHE_TO: '{{if ne .CACHE_EXPORT "false"}}--cache-to {{if eq .CACHE_BACKEND "gha"}}type=gha,scope=image,mode=max{{else}}type=local,dest={{.BUILDKIT_CACHE_DIR}}/image,mode=max,compression=zstd{{end}}{{end}}'
# mkosi's package cache, which is not a BuildKit cache because mkosi does not run under
# BuildKit (see image/Dockerfile). A rebuild of the base image is ~1.5 GB of apt.
MKOSI_CACHE_DIR: '{{.ROOT_DIR}}/.cache/mkosi'
IMAGE_BUILDER_TAG: spin-machine/image-builder:dev
tasks:
default:
cmds:
- task --list
# ==========================================================================
# Build
# ==========================================================================
build:
desc: Build QEMU, the guest kernel, e2fsprogs, the base image and the tools into _output/
cmds:
- task: qemu:build
- task: kernel:build
# Before the image, which is made with them.
- task: e2fsprogs:build
- task: image:build
- task: tools
- cmd: echo "✓ build complete — {{.OUTPUT_ABS}}/"
tools:
desc: >-
Build spin-machine: boots a VM of this machine, prints its fingerprint, and gives a
running one a disk or saves it (`spin-machine -h`).
sources:
- machine/**/*.go
- boot/*.go
- cmd/**/*.go
- go.mod
generates:
- '{{.OUTPUT_ABS}}/bin/spin-machine'
cmds:
- mkdir -p {{.OUTPUT_ABS}}/bin
- CGO_ENABLED=0 go build -ldflags '-s -w' -o {{.OUTPUT_ABS}}/bin/spin-machine ./cmd/spin-machine
# ==========================================================================
# Verify
# ==========================================================================
test:
desc: Test the machine definition, under the race detector, with its coverage in coverage.out and held to a floor.
vars:
# Below today's 84.7% - what CI measures, where `task test` runs before any QEMU is in
# _output and the tests that ask one skip; a checkout that has one reads about a point more.
# Raise it when the number has moved up and stayed; never lower it to let a change through,
# or it is a gate that reports whatever it is given.
COVERAGE_FLOOR: 80
cmds:
- go test -race -covermode=atomic -coverprofile=coverage.out ./...
# The anchor makes it the total line, not a function with "total" in its name.
- |
total=$(go tool cover -func=coverage.out | awk '/^total:/ {print $3}' | tr -d '%')
awk -v got="$total" -v floor={{.COVERAGE_FLOOR}} 'BEGIN {
if (got + 0 < floor + 0) { printf "coverage is %s%%, below the floor of %s%%\n", got, floor; exit 1 }
printf "coverage: %s%% (floor %s%%)\n", got, floor
}'
lint:
desc: >-
Everything CI checks that is not a build: formatting, vet, the Taskfiles parse, every
shell script is syntactically valid, and go-tools' gates over the Go and the prose.
cmds:
- |
set -euo pipefail
unformatted=$(gofmt -l machine cmd boot versions)
if [ -n "$unformatted" ]; then
echo "not gofmt'd:"; echo "$unformatted"; exit 1
fi
echo "OK: gofmt"
- go vet ./...
- cmd: 'echo "OK: go vet"'
# `task --list` parses every included Taskfile, so a syntax error or a name defined
# twice fails here rather than in whichever lane first called it.
- task --list > /dev/null
- cmd: 'echo "OK: the Taskfiles parse"'
- |
set -euo pipefail
for f in hack/release hack/fingerprint-diff hack/touches image/build.sh image/mkosi.postinst.chroot \
image/mkosi.extra/usr/local/lib/spin-base/*.sh boot/testdata/*.sh; do
bash -n "$f" || { echo "$f does not parse" >&2; exit 1; }
done
echo "OK: the shell scripts parse"
# go-tools' gates, at the version go.mod pins: every goroutine held to a context, no test
# that cannot fail, every path and task AGENTS.md and CLAUDE.md name resolves, and every
# mutate-exempt reason covers an edit mutate makes.
- go tool ctxlife
- go tool testquality ./...
- go tool refs
- go tool mutate -stale-exempts
- task: lint:workflows
lint:workflows:
desc: actionlint over .github/, and every action pinned by its commit.
cmds:
# -shellcheck= because actionlint runs whatever shellcheck is on PATH: a runner has one and
# a laptop may not, and a gate whose answer depends on the machine is not a gate.
- go run github.com/rhysd/actionlint/cmd/actionlint@$(go tool versions version actionlint) -shellcheck=
- cmd: 'echo "OK: actionlint"'
# A tag is whatever its owner points it at, and release.yml holds the token that publishes
# the tarball every host boots its machines from; the comment is the version Dependabot moves.
- |
if grep -rnE '^\s*(- )?uses: [^./][^ ]*@' .github | grep -vE '@[0-9a-f]{40} # v[0-9]'; then
echo "an action above is used by a tag its owner can move: pin it by commit SHA, with '# vX.Y.Z' after it"
exit 1
fi
echo "OK: every action pinned by its commit"
mutate:
desc: >-
Break what the change against BASE touched and fail on an edit no test refuses
(task mutate BASE=origin/main).
vars:
BASE: '{{.BASE | default "origin/main"}}'
cmds:
- go tool mutate -base {{.BASE}} -max 2000
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.
# And the chain over descriptors: what QEMU does with it, not only whether it parses.
- SPIN_MACHINE_OUTPUT={{.OUTPUT_ABS}} go test ./machine -run 'TestQEMUAcceptsEveryArgument|TestAChainOverDescriptors' -count=1 -v
fingerprint:
desc: >-
Print this machine's identity (machine.Spec.Fingerprint): the files a guest runs, by
content, and the arguments that decide its shape.
deps: [tools]
cmds:
- '{{.OUTPUT_ABS}}/bin/spin-machine fingerprint --release {{.OUTPUT_ABS}} {{.CLI_ARGS}}'
# ==========================================================================
# Release
# ==========================================================================
release:
desc: Build everything and pack one versioned tarball.
cmds:
- task: build
# hack/release decides the version (VERSION, or `git describe --tags --always --dirty`)
# and reads the pins and their checksums from versions.yaml itself: nothing is passed in
# that could disagree with what was built.
- OUTPUT_DIR="{{.OUTPUT_ABS}}" hack/release
# ==========================================================================
# Trying the machine
# ==========================================================================
shell:
desc: >-
Boot this machine and get a shell inside the base image, to see how it feels and what it
is missing: systemd, with the console logged in as root. INIT=/bin/bash boots a bare
shell instead; MEMORY and CPUS override the defaults.
aliases: [vm]
interactive: true
deps: [tools]
vars:
# systemd, because that is what this image is: a userland whose first process is
# /sbin/init. Booting a bare shell instead answers a different question than the one
# `task shell` is for — `systemd-analyze` in it replies "System has not been booted
# with systemd as init system (PID 1)", which is true and useless. INIT=/bin/bash is
# still there for when systemd is the thing that is broken.
INIT: '{{.INIT | default "/sbin/init"}}'
MEMORY: '{{.MEMORY | default "2048"}}'
CPUS: '{{.CPUS | default "2"}}'
cmds:
# Booted through _output/bin/spin-machine, which builds its command line from the
# machine package next door. That is the point of running it this way rather than
# writing the QEMU line here: the definition of the machine is executed by this
# repository, so a slot map or a kernel argument that stopped working stops working
# here first.
- |
set -euo pipefail
out={{.OUTPUT_ABS}}
for f in "$out/bin/spin-machine" "$out/bin/qemu-system-x86_64" "$out/kernel/vmlinux" \
"$out/image/rootfs.qcow2"; do
test -f "$f" || { echo "missing $f — run: task build" >&2; exit 1; }
done
# No --disk: spin-machine boots the base under a throwaway overlay (QEMU's
# -snapshot), so everything typed in this shell is written where a container's
# writes would go, and the base — which every VM maps read-only, many at once —
# cannot be touched. The hash around the session checks that rather than trusts it.
before=$(sha256sum "$out/image/rootfs.qcow2" | cut -d' ' -f1)
echo "booting {{.INIT}} in the base image — {{.MEMORY}} MiB, {{.CPUS}} vCPU"
echo " writes go to a throwaway overlay; the base is read-only and checked after"
case "{{.INIT}}" in
*/init) echo " systemd; the console autologins as root (the image carries no password)" ;;
esac
echo " leave with: 'poweroff -f', or Ctrl-A then X"
echo
# Root is /dev/vda straight into the image (the CLI's default), with no initrd at
# all. The kernel has virtio-blk and ext4 built in and build.sh writes a
# partitionless filesystem, so there is nothing for one to do: mounting root is the kernel's job here and
# systemd mounts the rest.
#
# Booting a debug initramfs here is a second init, and the two reasons for one are
# both gone. It mounted /proc, /sys, devtmpfs and devpts, which systemd does itself
# and only a bare `init=/bin/sh` ever needed. And it wrote a symlink enabling a
# serial login, which was needed while this image masked systemd-udevd: no
# dev-ttyS0.device unit existed and serial-getty@ttyS0's BindsTo could never be
# satisfied. udev runs; the getty starts on its own. Measured 2026-09-10: 207ms to a login
# prompt this way, against 163ms through the initramfs — 44ms for a second init
# doing what the first one does.
#
# accel=kvm with no fallback, from the machine package: the binary in _output/bin
# cannot emulate, on purpose. A host with no /dev/kvm gets an error here rather
# than a guest at a tenth of the speed.
"$out/bin/spin-machine" boot \
--release "$out" \
--memory {{.MEMORY}} --cpus {{.CPUS}} \
--init {{.INIT}} || true
echo
after=$(sha256sum "$out/image/rootfs.qcow2" | cut -d' ' -f1)
if [ "$before" != "$after" ]; then
echo "👹 the base image changed during that session: $before -> $after" >&2
echo " every overlay in existence is now built on a file that moved." >&2
exit 1
fi
echo "rootfs.qcow2 unchanged; the overlay's writes are gone"
report:
desc: >-
Every combination of the machine's features, and the image's boot variants, booted and
written as JSON to OUT: what each is, whether QEMU runs it, and what a boot of it costs.
`spin-machine compare --old A --new B` says what changed between two - two releases, or a
release and an experiment. Needs /dev/kvm and a built release (OUTPUT_DIR); sudo for the
variants that edit the image. REPS= boots per row, ONLY=<regexp> for some rows,
FLAGS="--kernel ... --append ..." for an experiment on every boot.
deps: [tools]
vars:
OUT: '{{.OUT | default (printf "%s/report.json" .OUTPUT_ABS)}}'
REPS: '{{.REPS | default "20"}}'
cmds:
# TMPDIR on disk, because the overlays and memory files go there: gigabytes a tmpfs
# /tmp holds in RAM.
- mkdir -p {{.OUTPUT_ABS}}/report-tmp
- >-
TMPDIR={{.OUTPUT_ABS}}/report-tmp SPIN_MACHINE_OUTPUT={{.OUTPUT_ABS}} SPIN_REPORT={{.OUT}}
SPIN_REPORT_ONLY='{{.ONLY}}' SPIN_REPORT_FLAGS='{{.FLAGS}}' REPS={{.REPS}}
go test ./boot/ -run '^TestReport$' -count=1 -v -timeout 6h
# ==========================================================================
# Keeping the pins and the tree
# ==========================================================================
versions:
desc: Say which of versions.yaml's pins are behind their upstream (exits 1 when any is).
cmds:
- '{{.VERSIONS}} check'
bump:
desc: >-
Pin one entry of versions.yaml at a version, the newest by default
(task bump NAME=qemu [TO=11.2.0]). Nothing is built: the entry's note says what the
bump is checked with.
requires:
vars: [NAME]
# TO and not VERSION: VERSION is the release's version, set in a release run's environment.
# Named VERSION, a bump with no version asked for mkosi v20260927.01-2-g30b717b.
cmds:
- '{{.VERSIONS}} bump {{.NAME}} {{.TO}}'
clean:
desc: Remove build output. Leaves the caches; use clean:cache for those.
cmds:
- rm -rf {{.OUTPUT_ABS}}
clean:cache:
desc: Remove the BuildKit and mkosi caches. A full rebuild after this is hours.
cmds:
- rm -rf {{.BUILDKIT_CACHE_DIR}} {{.MKOSI_CACHE_DIR}}