Repository navigation
Expand file tree
/
Copy pathTaskfile.yml
More file actions
883 lines (820 loc) · 43.2 KB
/
Copy pathTaskfile.yml
File metadata and controls
883 lines (820 loc) · 43.2 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
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
# 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` is the machine's contract - its versioned
# machine type, shape, devices and BIOS size - and the binaries, the kernel and the firmware are
# what make that contract run: built together, checked together (boot:compat, boot:roms), and
# shipped as one tarball with one version, because 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: a pull request's
# build, a release and compat read main's scopes, and what they compiled nobody reads after.
# 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_EXE}} --list'
# ==========================================================================
# Build
# ==========================================================================
apk:lock:
desc: Pin every package QEMU's and e2fsprogs' builders install (each part's apk.list) at what the pinned Alpine's mirror has today, into its apk.lock.
# A fresh install of the list on the pinned Alpine, its database read back: the versions the
# mirror serves now, which is what a bump means. A new lock is a new QEMU or e2fsprogs build:
# the release notes say what moved, and boot:compat that checkpoints still resume on it.
cmds:
- for: [qemu, e2fsprogs]
cmd: |
set -euo pipefail
db=$(mktemp)
docker run --rm -v {{.ROOT_DIR}}/{{.ITEM}}/apk.list:/apk.list:ro $({{.VERSIONS}} ref alpine) \
sh -c 'apk add --no-cache $(grep -v "^#" /apk.list) > /dev/null && cat /lib/apk/db/installed' > "$db"
go run ./hack/apklock "$db" > {{.ITEM}}/apk.lock
rm -f "$db"
echo "{{.ITEM}}/apk.lock: $(wc -l < {{.ITEM}}/apk.lock) packages"
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 workflows)
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_EXE}} --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, every
# mutate-exempt reason covers an edit mutate makes, every workflow step is one task, and
# pnpm is the one package manager, installing through Socket Firewall.
- go tool ctxlife
- go tool testquality ./...
- go tool refs
- go tool mutate -stale-exempts
- go tool taskgate
- go tool sfw check
- 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, the default). In CI the base is what the change is to: a
pull request's target branch, or on a push what the push replaced (BEFORE).
cmds:
- |
set -euo pipefail
base='{{.BASE}}'
if [ -z "$base" ] && [ "${GITHUB_ACTIONS:-}" = true ]; then
if [ "$GITHUB_EVENT_NAME" = pull_request ]; then
base="origin/$GITHUB_BASE_REF"
else
base="${BEFORE:-}"
fi
# A push that created its branch has no commit before it.
git rev-parse --verify "$base^{commit}" >/dev/null 2>&1 || base=HEAD~1
fi
go tool mutate -base "${base:-origin/main}" -max 2000
coverage:
desc: >-
What `task test` covered, function by function: on the run's summary in CI, on stdout
here, and as coverage.html.
cmds:
- |
set -e
summary=${GITHUB_STEP_SUMMARY:-/dev/stdout}
if [ ! -f coverage.out ]; then
echo "coverage.out was not produced; the tests may have failed before coverage ran." >> "$summary"
exit 0
fi
{
echo "## Go coverage"
echo
echo "**$(go tool cover -func=coverage.out | awk '/^total:/ {print $3}')** total"
echo
echo '<details><summary>Function detail</summary>'
echo
echo '```'
go tool cover -func=coverage.out
echo '```'
echo '</details>'
} >> "$summary"
go tool cover -html=coverage.out -o coverage.html
touches:
desc: >-
Whether the commits since BASE can change what one part builds, as build=true or
build=false, on the step's outputs in CI (task touches BASE=<commit> -- <entry>... --
<path>...; hack/touches says how it decides).
cmds:
- |
set -e
build=$(hack/touches "${BASE:-}" {{.CLI_ARGS}})
echo "build=${build}" | tee -a "${GITHUB_OUTPUT:-/dev/null}"
vsock:
desc: >-
Make /dev/vhost-vsock usable by this user, so verify:args can create the machine's vsock
device. Without it, says so rather than failing: verify:args names the device unchecked.
cmds:
# The module before the node is looked at: GitHub's runner has the static node before the
# module is loaded, the first open loads it, and udev then sets the node back to root's -
# QEMU got "Permission denied" after a chmod that saw the node and skipped the modprobe
# (2026-10-24). A kernel that builds it in - Spin OS's, which has no modules - has the
# device from boot, and modprobe there has nothing to find.
- |
sudo modprobe vhost_vsock 2>/dev/null || true
sudo udevadm settle 2>/dev/null || true
{ [ -c /dev/vhost-vsock ] && sudo chmod 0666 /dev/vhost-vsock; } ||
echo "no vhost_vsock here; the check will name vhost-vsock-pci as unchecked"
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
# ==========================================================================
osv:
desc: >-
What OSV knows against the newest release's SBOM, once its signature is the release's: one
line a finding, id, package@version and summary, in _output/osv.tsv. Needs gh, cosign and
the network; osv.yml runs it daily and keeps an issue for each package with findings
(hack/osvissues).
# The newest release, because it is what anything takes next; an older one still pinned is
# its consumer's to scan, which spin's control plane does for what it was installed with.
cmds:
- |
set -euo pipefail
tag=$(gh release view --json tagName -q .tagName)
dir=$(mktemp -d); trap 'rm -rf "$dir"' EXIT
sbom="spin-machine-${tag}-linux-x86_64.cdx.json"
gh release download "$tag" -p "$sbom" -p "$sbom.sigstore.json" -D "$dir"
cosign verify-blob --bundle "$dir/$sbom.sigstore.json" \
--certificate-identity https://github.com/spin-stack/spin-machine/.github/workflows/release.yml@refs/heads/main \
--certificate-oidc-issuer https://token.actions.githubusercontent.com "$dir/$sbom"
# osv-scanner exits 1 when it found something, which is the answer; above 1 it failed.
status=0
go run "github.com/google/osv-scanner/v2/cmd/osv-scanner@$(go tool versions version osv-scanner)" \
scan source -L "$dir/$sbom" --format json > "$dir/osv.json" || status=$?
[ "$status" -le 1 ] || exit "$status"
mkdir -p _output
jq -r '.results[]?.packages[]? | .package as $p | .vulnerabilities[]? | [.id, ($p.name + "@" + $p.version), (.summary // "")] | @tsv' \
"$dir/osv.json" | sort -u > _output/osv.tsv
echo "$tag: $(wc -l < _output/osv.tsv) finding(s), in _output/osv.tsv"
osv:issues:
desc: >-
Bring the issues to what task osv found: one open for each package with findings, closed
once the newest release has none (hack/osvissues says why a package and not a finding).
Needs gh.
cmds:
- go run ./hack/osvissues -release "$(gh release view --json tagName -q .tagName)" -findings _output/osv.tsv
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
# What release.yml does around `task release`, in its order. The version and the identity a file
# is signed as are the workflow's to know, and come in its environment.
release:version:
desc: >-
The version a release is: INPUT_VERSION, or a new one for today, counted from the tags
today already has. On the step's outputs in CI.
cmds:
- |
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:-/dev/stdout}"
echo "Releasing $version — $origin"
release:notes:
desc: >-
The notes of the release VERSION built in _output/release: what changed since the release
before it, whether its machine is that one's (hack/fingerprint-diff), and its machine.env.
On the step's outputs in CI. Needs gh.
cmds:
- |
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
# 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
{
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:-/dev/stdout}"
release:provenance:
desc: >-
Who built the release VERSION and from what - this commit, every pin in versions.yaml - as
SLSA provenance, in _output/provenance.json.
cmds:
- go tool provenance -version "${VERSION}" -out _output/provenance.json
release:sign:
desc: >-
Sign and attest every file of _output/release as IDENTITY, the workflow at its ref, and
check each bundle against it. Needs cosign and an OIDC token, so it is release.yml's.
# An attestation is checked by the file's digest, not the file: verify-blob-attestation reads
# a blob whole and refuses one over COSIGN_MAX_ATTACHMENT_SIZE (128 MiB), and the tarball is
# 338 MB (v20261013.01's run failed there). The digest is what the in-toto subject names.
cmds:
- |
set -euo pipefail
attested() {
cosign verify-blob-attestation --type "$1" --bundle "$2" \
--digest "$(sha256sum "$3" | cut -d' ' -f1)" --digestAlg sha256 \
--certificate-identity "${IDENTITY}" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
}
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}"
attested slsaprovenance1 "${f}.provenance.sigstore.json" "${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
attested cyclonedx "${f}.cdx.sigstore.json" "${f}"
done
go tool attestgate files -require provenance _output/release
go tool attestgate files -require cdx -except '*.sha256,machine.env,*.cdx.json' _output/release
release:tag:
desc: Tag this commit VERSION and push the tag. A version tagged already is refused, since git push does not move a tag.
cmds:
- |
set -euo pipefail
git tag "$VERSION"
git push origin "$VERSION"
# ==========================================================================
# 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
# ==========================================================================
# The KVM runner: what lab.yml and report.yml do around a target
# ==========================================================================
#
# In their order. What the dispatch asked for comes in the job's environment (RELEASE,
# EXPERIMENT, KERNEL_RUN, KERNEL_RUN_C, QEMU_RUN, VARIANT, VARS; VERSION for a report), and what
# one step finds reaches the next through $GITHUB_ENV.
lab:unpack:
desc: The release RELEASE, or the newest, unpacked into _output/lab as the tree the experiment boots. Needs gh.
cmds:
- |
set -euo pipefail
if [ -z "$RELEASE" ]; then
RELEASE=$(gh release list -R "$GITHUB_REPOSITORY" --exclude-drafts --exclude-pre-releases --limit 1 --json tagName -q '.[0].tagName')
fi
rm -rf _output/lab
mkdir -p _output/lab/tmp
gh release download "$RELEASE" -R "$GITHUB_REPOSITORY" -p '*.tar.gz' -D _output/lab
tar -xzf _output/lab/*.tar.gz -C _output/lab
{
echo "RELEASE=$RELEASE"
echo "SPIN_MACHINE_OUTPUT=$PWD/_output/lab/spin-machine-${RELEASE}-linux-x86_64/usr/share/spin-stack"
# On disk: the overlays and memory files go here, gigabytes a tmpfs /tmp holds in RAM.
echo "TMPDIR=$PWD/_output/lab/tmp"
} >> "${GITHUB_ENV:-/dev/stdout}"
lab:fetch:
desc: The builds to compare, from the runs KERNEL_RUN, KERNEL_RUN_C and QEMU_RUN name, into _output/lab. Needs gh.
cmds:
- |
set -euo pipefail
fetch() { # run, artifact pattern, directory
[[ "$1" =~ ^[0-9]+$ ]] || { echo "::error::a run id is a number, got: $1"; exit 1; }
gh run download "$1" -R "$GITHUB_REPOSITORY" -p "$2" -D "$3"
}
if [ -n "$KERNEL_RUN" ]; then fetch "$KERNEL_RUN" 'vmlinux-*' _output/lab/kernel-b; fi
if [ -n "$KERNEL_RUN_C" ]; then fetch "$KERNEL_RUN_C" 'vmlinux-*' _output/lab/kernel-c; fi
if [ -n "$QEMU_RUN" ]; then fetch "$QEMU_RUN" 'qemu-*-linux-amd64' _output/lab/qemu-b; fi
lab:place:
desc: >-
Put the fetched builds where the experiment boots them: beside the release's (VARIANT=beside)
or in place of it (VARIANT=instead).
cmds:
- |
set -euo pipefail
env=${GITHUB_ENV:-/dev/stdout}
one() { # the one file under $1 whose path matches $2
f=$(find "$1" -path "$2" -type f | head -1)
[ -n "$f" ] || { echo "::error::$1 has no $2"; exit 1; }
echo "$PWD/$f"
}
b=$PWD/_output/lab/release-b
tree_b() { [ -d "$b" ] || cp -as "$SPIN_MACHINE_OUTPUT" "$b"; }
if [ -n "$KERNEL_RUN" ]; then
k=$(one _output/lab/kernel-b '*/vmlinux')
if [ "$VARIANT" = instead ]; then
tree_b; rm -f "$b/kernel/vmlinux"; install -m 0644 "$k" "$b/kernel/vmlinux"
else
echo "SPIN_KERNEL_B=$k" >> "$env"
fi
fi
if [ -n "$KERNEL_RUN_C" ]; then
[ "$VARIANT" = beside ] || { echo "::error::kernel_run_c is a third kernel beside the release, not one to boot instead"; exit 1; }
echo "SPIN_KERNEL_C=$(one _output/lab/kernel-c '*/vmlinux')" >> "$env"
fi
if [ -n "$QEMU_RUN" ]; then
q=$(one _output/lab/qemu-b '*/bin/qemu-system-x86_64')
tree_b; rm -rf "$b/bin/qemu-system-x86_64" "$b/qemu"
install -m 0755 "$q" "$b/bin/qemu-system-x86_64"
cp -r "$(dirname "$(dirname "$q")")/qemu" "$b/qemu"
[ "$VARIANT" = instead ] || echo "SPIN_MACHINE_OUTPUT_B=$b" >> "$env"
fi
if [ "$VARIANT" = instead ]; then echo "SPIN_MACHINE_OUTPUT=$b" >> "$env"; fi
lab:vars:
desc: >-
VARS, `KEY=VALUE; KEY=VALUE`, for the experiment: only the names a target reads, so a typo
fails here rather than being ignored, and none of the ones lab:unpack and lab:place set.
cmds:
- |
set -euo pipefail
IFS=';' read -ra pairs <<< "$VARS"
for pair in "${pairs[@]}"; do
pair="${pair#"${pair%%[![:space:]]*}"}"
pair="${pair%"${pair##*[![:space:]]}"}"
[ -z "$pair" ] && continue
key="${pair%%=*}"
[[ "$pair" == *=* && "$key" =~ ^(REPS|TOP|GAP|ONLY|FLAGS|CPUS|MEMORY_MIB|DEBUG|FILE_MB|CACHE_TIMEOUT_S|GRAPH_[A-Z_]+|SHARE_[A-Z_]+|SPIN_[A-Z0-9_]+)$ ]] ||
{ echo "::error::not a variable this workflow passes on: $pair"; exit 1; }
case "$key" in
SPIN_MACHINE_OUTPUT|SPIN_MACHINE_OUTPUT_B|SPIN_KERNEL_B|SPIN_KERNEL_C) echo "::error::$key is set by this workflow"; exit 1 ;;
esac
echo "$pair" >> "${GITHUB_ENV:-/dev/stdout}"
done
lab:about:
desc: What this lab run is and the host it runs on, on the run's summary in CI; fails without a usable /dev/kvm.
cmds:
- |
set -e
{
echo "### $EXPERIMENT on ${RUNNER_NAME:-$(hostname)}"
echo
echo "- release: $RELEASE"
echo "- kernel B: ${KERNEL_RUN:+run }${KERNEL_RUN:-none}"
echo "- kernel C: ${KERNEL_RUN_C:+run }${KERNEL_RUN_C:-none}"
echo "- QEMU B: ${QEMU_RUN:+run }${QEMU_RUN:-none}"
if [ -n "$KERNEL_RUN$KERNEL_RUN_C$QEMU_RUN" ]; then echo "- booted: $VARIANT the release"; fi
echo "- vars: ${VARS:-none}"
echo "- CPU: $(sed -n 's/^model name\s*: //p' /proc/cpuinfo | head -1), $(nproc) CPUs"
echo "- load: $(cut -d' ' -f1-3 /proc/loadavg)"
if sudo -n true 2>/dev/null; then echo "- sudo -n: yes"; else echo "- sudo -n: **no** (targets that edit an overlay skip)"; fi
} | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}"
[ -r /dev/kvm ] && [ -w /dev/kvm ] || { echo "::error::no usable /dev/kvm"; exit 1; }
lab:run:
desc: >-
The experiment EXPERIMENT, on the tree SPIN_MACHINE_OUTPUT, logged to _output/lab/lab.log;
report writes its JSON to _output/lab/report.json.
# A skipped test exits 0, and a green run that measured nothing reads as a result, so a skip
# fails the run with the test's reason.
cmds:
- |
set -o pipefail
{{.TASK_EXE}} "$EXPERIMENT" OUTPUT_DIR="$SPIN_MACHINE_OUTPUT" OUT="$PWD/_output/lab/report.json" 2>&1 | tee _output/lab/lab.log
if grep -q -- '--- SKIP' _output/lab/lab.log; then
echo "::error::the experiment skipped: $(grep -B1 -- '--- SKIP' _output/lab/lab.log | head -1 | sed 's/^.*_test.go:[0-9]*: //')"
exit 1
fi
lab:found:
desc: What the experiment printed, from _output/lab/lab.log, on the run's summary in CI.
cmds:
- |
set -e
[ -f _output/lab/lab.log ] || exit 0
{
echo
echo '```'
grep -E '^\s{4}\S' _output/lab/lab.log | sed 's/^ //' | tail -300
echo '```'
} >> "${GITHUB_STEP_SUMMARY:-/dev/stdout}"
report:runner:
desc: What this host can measure, said before anything is, on the run's summary in CI; fails without a usable /dev/kvm.
# A row the report skips is one this host lacks the device for, and this says which first.
cmds:
- |
set -e
{
echo "### Runner ${RUNNER_NAME:-$(hostname)}"
echo
for dev in /dev/kvm /dev/vhost-vsock; do
if [ -r "$dev" ] && [ -w "$dev" ]; then echo "- $dev: usable"; else echo "- $dev: **not usable** ($(ls -l "$dev" 2>&1))"; fi
done
if sudo -n true 2>/dev/null; then
echo "- sudo -n: yes"
sudo -n modprobe nbd max_part=8 2>/dev/null || true
for _ in $(seq 50); do [ -e /dev/nbd0 ] && break; sleep 0.02; done
if [ -e /dev/nbd0 ]; then echo "- /dev/nbd0: present (image variants and the echo login run)"; else echo "- /dev/nbd0: **missing** (image variants are skipped, the login is agetty's)"; fi
else
echo "- sudo -n: **no** (image variants are skipped, the login is agetty's)"
fi
echo "- gh: $(command -v gh || echo '**missing**')"
echo "- CPU: $(sed -n 's/^model name\s*: //p' /proc/cpuinfo | head -1), $(nproc) CPUs"
} | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}"
[ -r /dev/kvm ] && [ -w /dev/kvm ] || { echo "::error::no usable /dev/kvm: nothing here can be measured"; exit 1; }
report:unpack:
desc: >-
The release VERSION's tarball, as a consumer gets it, unpacked into _output/report: what is
measured is what was published. Needs gh.
cmds:
- |
set -euo pipefail
rm -rf _output/report
mkdir -p _output/report
gh release download "$VERSION" -R "$GITHUB_REPOSITORY" -p '*.tar.gz' -D _output/report
tar -xzf _output/report/*.tar.gz -C _output/report
echo "RELEASE_TREE=$PWD/_output/report/spin-machine-${VERSION}-linux-x86_64/usr/share/spin-stack" >> "${GITHUB_ENV:-/dev/stdout}"
report:compare:
desc: >-
_output/report/report.json against the newest release before VERSION that published one,
in compare.md beside it and on the run's summary in CI. Needs gh.
cmds:
- |
set -euo pipefail
cd _output/report
previous=""
for tag in $(gh release list -R "$GITHUB_REPOSITORY" --limit 30 --json tagName -q '.[].tagName'); do
[ "$tag" = "$VERSION" ] && continue
if gh release download "$tag" -R "$GITHUB_REPOSITORY" -p report.json -O previous.json 2>/dev/null; then
previous="$tag"
break
fi
done
if [ -n "$previous" ]; then
"$RELEASE_TREE/bin/spin-machine" compare --old previous.json --new report.json > compare.md
else
echo "No earlier release published a report.json to compare the feature matrix with." > compare.md
fi
cat compare.md >> "${GITHUB_STEP_SUMMARY:-/dev/stdout}"
report:publish:
desc: Upload _output/report/report.json to the release VERSION and append compare.md to its notes. Needs gh.
cmds:
- |
set -euo pipefail
cd _output/report
gh release upload "$VERSION" -R "$GITHUB_REPOSITORY" report.json --clobber
gh release view "$VERSION" -R "$GITHUB_REPOSITORY" --json body -q .body > notes.md
{ echo; cat compare.md; } >> notes.md
gh release edit "$VERSION" -R "$GITHUB_REPOSITORY" --notes-file notes.md
# ==========================================================================
# 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}}'
# A QEMU bump keeps the machine type (machine.MachineType), and the bump's pull request
# shows what the newer types change against it, from the source just pinned.
- cmd: '{{.TASK_EXE}} qemu:compat'
if: '[ "{{.NAME}}" = qemu ]'
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}}