Repository navigation
ci: build every board the same way, closure and image - #57
Merged
Merged
Conversation
cache-rpi.yml existed for a good reason: there is no local Pi builder, and compiling the linux-rpi kernel under binfmt emulation on an x86 box takes hours, so the aarch64 closure was built on GitHub's native arm64 runner and pushed to Attic. Nothing about that reasoning is Pi-specific — it is only where the pain was felt first. release-image.yml built the x86_64 image on an x86 runner and pushed no closure at all. So boards.yml replaces both: a `closure` job matrixed over every board, each on a runner of its own architecture, and an `image` job matrixed over every board, always on x86 because disko assembles an image by booting a QEMU VM and the arm64 runners have no /dev/kvm. Pushing the x86_64 closure matters for a narrower reason than cache coverage. cache.nixos.org does serve the pinned nixos-unstable revision every board builds from (docs/adr/0008), so most of an x86 closure substitutes from upstream. What never substitutes is a *system closure*: the daemon, sendspin-go, vboard, the kiosk configuration and the toplevel itself are this repository's own output, and no public cache has ever held them. Until now an x86 candidate could therefore not be fetched by a device at all — an OTA update compiled on the device, and the release gate's update phase could not be gated on that board. The Pi's kernel is a difference of degree, not of kind. The two workflows had also drifted in ways nobody intended, and each had half of what both should have had. Only the Pis had their closure pushed and their cache warmed on a push to main; only x86_64 had its baked version asserted against the tag. Both now get both, and the R2 publish with its provenance metadata exists once instead of in two copies that had already diverged on what they recorded. Board-specific assertions stay board-specific: the U-Boot UEFI loader and the Pi boot chain on the ESP are Pi-only, because an x86 board has no U-Boot in its chain and an assertion passing vacuously there would be worse than none. The seed-file drop zone staged through systemd-boot extraFiles is shared, so it is now asserted for every board — checked by eval against all three before generalising it, rather than assumed. The x86 image is now compressed with --long=27 like the Pi images, rather than plain -19. Verified that a window log of 27 still decompresses with the bare `zstd -dc` that docs/flash/flash.md tells users to run: it is exactly at zstd's default decoder limit, which is presumably why 27 was chosen. labgrid/candidate.py loses its per-board cache-source map along with the "and nothing pushes the x86_64 closure" branch of its failure message, since every board now has the same answer. Its guard that the named workflow actually exists is new, and is what a rename like this one should trip over next time. main is not a protected branch, so no required status checks need updating for the new run names. Closes #55 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`needs` takes a whole job and never one leg of its matrix, and `fail-fast: false` does not change that. So the two matrixed jobs boards.yml had gated every board's image on *every* board's closure: a flaky ubuntu-24.04-arm runner, or an rpi4-only eval break, and a v* tag published no images at all, x86_64's included, however green its own closure was. The two workflows this replaced at least failed one architecture at a time, so that was a regression the merge introduced rather than a wart it inherited. Merging closure and image into one job per board is not available: the Pi closure has to run on a native arm64 runner while every image has to run on x86, because disko boots a QEMU VM and the arm64 runners have no /dev/kvm. Letting the image job run past a failed closure with `!cancelled()` is not available either — the tag version guard lives in the closure job, so an image leg that ran on would publish a wrongly-named object. So the per-board pair moves to board.yml behind `on: workflow_call`, and boards.yml matrixes over the boards and calls it once each. Each call is its own pair of jobs, so `needs` means that board alone: rpi4 failing skips rpi4's image, and x86_64 does not wait for it either. board.yml carries no push or workflow_dispatch trigger, so boards.yml is still the one entry point. Workflow-level `env` does not cross into a called workflow, so board.yml declares ATTIC_ENDPOINT, ATTIC_CACHE and R2_ENDPOINT itself; `concurrency` stays on the caller and still governs the whole run. `github.ref_type` and `github.event_name` in a called workflow are the caller's triggering event, so the tag version guard and the image job's `if:` are unchanged. Run names become `<board> / closure -> cache` and `<board> / image -> R2`. test_no_board_waits_on_another_boards_closure asserts the shape over every workflow, not over boards.yml alone, because the cheap way to reintroduce this is to inline board.yml back. pyyaml is named in labgridPython rather than left to arrive with labgrid, since a test now depends on it. Also corrects the header's claim that release-please.yml "builds the same R2 paths" for the release notes' download links. It builds none: a candidate's notes carry no links at all, and promotion writes them by hand today (docs/adr/0011). What does hard-code the object names is docs/flash/flash.md, the filename table users read while flashing — so that is what the comment now sends people to. labgrid/README.md pointed at "boards.yml's `closure` job", which now lives in board.yml. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
cache-rpi.ymlexisted for a good reason: there is no local Pi builder, andcompiling the
linux-rpikernel under binfmt emulation on an x86 box takeshours, so the aarch64 closure was built on GitHub's native arm64 runner and
pushed to Attic. Nothing about that reasoning is Pi-specific — it is only where
the pain was felt first.
Why x86_64 needs its closure pushed, and the reason is narrower than cache
coverage.
cache.nixos.orgdoes serve the pinnednixos-unstablerevisionevery board builds from (ADR 0008),
so most of an x86 closure substitutes from upstream. What never substitutes is
a system closure: the daemon,
sendspin-go,vboard, the kioskconfiguration and the toplevel itself are this repository's own output, and no
public cache has ever held them. So until now an x86 candidate could not be
fetched by a device at all — an OTA update compiled on the device, and the
release gate's update phase could not be gated on that board. The Pi's
kernel is a difference of degree, not of kind.
Nothing is cross-compiled, and nothing compiles under emulation. Each board
sets its own
nixpkgs.hostPlatform, so nixpkgs evaluates a native build —and an emulated-native aarch64 build yields byte-identical store paths to a
truly native one, which is exactly what lets the closure the arm64 runner just
pushed substitute into the image job and onto a device.
pkgsCrosswouldproduce different paths, every one of those lookups would miss, and the
native-runner job would buy nothing. The only emulated work left is
nixos-install's chroot runningswitch-to-configuration boot— a fewactivation scripts. (A comment inherited from
cache-rpi.ymlcalled that"cross-build"; corrected here.)
The two workflows had also drifted, each holding half of what both should have
had:
mainBoard-specific assertions stay board-specific. The U-Boot UEFI loader and the
Pi boot chain on the ESP are Pi-only — an x86 board has no U-Boot in its chain,
and an assertion passing vacuously there would be worse than none. The
seed-file drop zone staged through
systemd-boot.extraFilesis shared, so it isnow asserted for every board.
Evidence
Before: two workflows, two shapes, and
labgrid/test_update.pyreporting the x86_64 closure as a gap by name.After, each thing that could have broken a board, checked rather than
assumed:
The assertion promoted from Pi-only to shared actually holds on all three
boards — evaluated before generalising it:
Every image attribute the new matrix builds resolves:
The x86 image now compresses with
--long=27like the Pi images rather thanplain
-19. A window log of 27 is exactly zstd's default decoder limit, so thebare
zstd -dcthatdocs/flash/flash.mdtells users to run still works —compressed and decompressed a 200 MB file to confirm rather than trusting it:
Lint and tests:
The per-board gate has a guard, and it was written red against the shape it
replaces before being made green (see Review follow-up below):
labgrid/candidate.pyloses its per-board cache-source map and the "andnothing pushes the x86_64 closure" branch of its failure message, since every
board now has the same answer. Its new guard is that the workflow it names
actually exists on disk — which is what a rename like this one should trip over
next time:
Not reproducible in review, and not by a dispatch on this branch either:
GitHub only offers
workflow_dispatchfor a workflow already on the defaultbranch, so
boards.ymlcannot be dispatched until this merges. See below.Merge Danger
Door: two-way to revert, but the release path is live in between.
Reverting restores both files unchanged. What cannot be undone by a revert is a
release cut while this is broken — but the blast radius of that is now one
board, not the fleet: each board's image gates on its own closure alone, so a
Pi failing no longer takes the x86_64 release artifact down with it, which is
the one way the old split was better and is no longer.
Recommended before the next tag: merge, then run
boards.ymlonce viaworkflow_dispatchonmain— it is not dispatchable before then. Thatexercises every path except the tag-only version guard: all three closures
pushed, all three images built and published to
dev/<board>/, where thelifecycle policy sweeps them after 7 days.
Blast Radius: the release flow, and the cache.
rpi4 / closure -> cache,x86_64 / image -> R2insteadof
build/image).mainis not a protected branch, so no required statuschecks need updating.
objects in the cache, nothing displaced.
mainnow builds three closures instead of two, so it costs onemore runner. The image job is still tag- and dispatch-only.
ATTIC_TOKENis now needed by a job that runs on an x86 runner too. Samesecret, same scope; it just has one more consumer.
flake.nix,justfile,modules/core/binary-cache.nix,ops/README.md,release-please.yml,labgrid/README.md). No other workflow builds these R2 paths: a candidate'snotes carry no download links at all and promotion writes them by hand today
(ADR 0011).
The only place hard-coding the object names is
docs/flash/flash.md, thefilename table users read while flashing, and those names are unchanged.
Review follow-up (
dff3895)A two-axis review of the first commit found one thing it got wrong and one
thing it claimed wrongly. Both are fixed on the branch.
needs: closurecoupled the boards.needstakes a whole job and neverone leg of its matrix, and
fail-fast: falsedoes not change that — so the twomatrixed jobs gated every board's image on every board's closure. A flaky
arm64 runner and a
v*tag published nothing, x86_64 included, however greenits own closure was. That was a regression this PR introduced, not a wart it
inherited.
Neither obvious fix was available. Merging closure and image into one job per
board is impossible: the Pi closure needs a native arm64 runner while every
image needs x86, because disko boots a QEMU VM and the arm64 runners have no
/dev/kvm. Running the image job past a failed closure with!cancelled()isunsafe: the tag version guard lives in the closure job, so an image leg that
ran on would publish a wrongly-named object.
So the per-board pair moved to
board.ymlbehindon: workflow_call, andboards.ymlmatrixes over the boards and calls it once each — GitHub's ownanswer to per-leg
needs. Each call is its own pair of jobs, soneedsmeansthat board alone, and x86_64 no longer waits on rpi4's closure either.
board.ymlcarries nopush/workflow_dispatchtrigger, soboards.ymlisstill the one entry point.
Three things the split forced, none of which change behaviour: workflow-level
envdoes not cross into a called workflow, soboard.ymldeclaresATTIC_ENDPOINT/ATTIC_CACHE/R2_ENDPOINTitself (concurrencystays on thecaller and still governs the whole run);
secrets: inheritcarriesATTIC_TOKENand the two R2 keys; andgithub.ref_type/github.event_nameina called workflow are the caller's triggering event, so the tag version guard
and the image job's
if:are untouched.The header claimed
release-please.yml"builds the same R2 paths". Itbuilds none. Corrected in the workflow header and in Blast Radius above.
labgrid/README.mdalso pointed at "boards.yml'sclosurejob", which nowlives in
board.yml.Closes #55
🤖 Generated with Claude Code