Skip to main content

max / alloy

Gate both builders on the preflight, and refuse only on the image It was not a gate on day one on purpose: one that fires wrongly the first day is worse than none, and the preflight had never been run in anger. It has since caught four things nobody predicted, so the condition is met. The policy lives in build/preflight-gate.sh, sourced by both builders, because two copies of it would drift. WHAT REFUSES. dials, requires and var-payload describe the medium, and an image carrying one of those findings is wrong wherever it is built. guards describes the machine: it reads sibling repos under ~/Code that no image contains, so a stale goingson clone predicts a failed AppImage here and says nothing about what is being minted. It warns loudly and the build goes on. A gate that refused to build a good ISO because a repo next door was behind is one people route around, and routing around it is how those four findings would have been missed. The two kinds are now two exit codes, 1 and 4, which is what makes the split expressible. HOW MUCH RUNS. The probe is one dnf transaction: seconds native, minutes emulated. So the gate runs it when the medium's architecture is this machine's and passes --fast when it is not, and says which it chose. A mint with no --host is not gated, since there is no recipe to check, and that is printed rather than silent. --no-preflight skips it for a deliberately incomplete image. TWO BUGS FOUND BY MAKING IT A GATE, both of which would have made it useless: The EXIT trap ended on `[ -n "$probe" ] && rm -rf "$probe"`, and bash lets an EXIT trap set the script's status. Whenever the probe was never built -- which is every --fast run -- a clean preflight printed "preflight: clean" and exited 1. Harmless while nothing read the code, fatal the moment a builder gated on it, and --fast is exactly what a cross-architecture mint runs, so astra's every mint would have been refused. A dials failure still paid for the whole probe, measuring properties of a mint that cannot happen and making the gate slowest precisely when it is about to refuse. It now skips the probe: 0.3s instead of minutes. Verified: exit 0/1/2/4 each produced and handled, on both the fast and the probe path, and a bad recipe stops build-iso.sh before podman build.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01WFBzMprSmNCfvdj2cGZyka
Author: Max Johnson <me@maxj.phd> · 2026-09-05 16:26 UTC
Signed with PGP, not checked
Commit: be542d2f5f88857cffcdd627070f047e71955cab
Parent: f2c6f99
5 files changed, +218 insertions, -3 deletions
@@ -26,6 +26,7 @@
26 26 # build/build-image.sh # build image + raw disk image
27 27 # build/build-image.sh --type qcow2 # build image + qcow2 disk image
28 28 # build/build-image.sh --host fw13 # mint with fw13's recipe
29 + # build/build-image.sh --no-preflight # mint a knowingly incomplete image
29 30 # build/build-image.sh --skip-build # reuse the current image, just run bib
30 31 # build/build-image.sh --write-only --write /dev/sdX # write what is already built
31 32 # build/build-image.sh --write /dev/sdX # also dd the artifact to a device
@@ -54,6 +55,11 @@
54 55 # shellcheck source=build/host-recipe.sh
55 56 . "$REPO_ROOT/build/host-recipe.sh"
56 57
58 + # preflight_gate. Runs build/preflight.sh ahead of the mint and decides what a
59 + # finding means; see the header of build/preflight-gate.sh.
60 + # shellcheck source=build/preflight-gate.sh
61 + . "$REPO_ROOT/build/preflight-gate.sh"
62 +
57 63 IMAGE="localhost/alloy:local"
58 64 BIB_IMAGE="quay.io/centos-bootc/bootc-image-builder:latest"
59 65 DEF="$REPO_ROOT/build/bib-defs.yaml"
@@ -75,6 +81,8 @@
75 81 # carry no compiler and no database. See build/host-recipe.sh.
76 82 HOST=""
77 83 SKIP_BUILD=0
84 + # The preflight runs ahead of every mint that has a recipe. See --no-preflight.
85 + NO_PREFLIGHT=0
78 86 SKIP_BIB=0
79 87
80 88 BUILD_ARGS=()
@@ -95,6 +103,9 @@
95 103 --type) TYPE="${2:?--type needs a value}"; shift 2 ;;
96 104 --write) WRITE_DEV="${2:?--write needs a device path}"; shift 2 ;;
97 105 --skip-build) SKIP_BUILD=1; shift ;;
106 + # Mint without running build/preflight.sh first, for deliberately building a
107 + # known-incomplete image. Not a way past a finding you would rather not read.
108 + --no-preflight) NO_PREFLIGHT=1; shift ;;
98 109 # --skip-bib is the older spelling, kept working. --write-only is the
99 110 # name both scripts answer to, because "bib" means nothing in the ISO
100 111 # path and the console emits one flag for both artifacts.
@@ -140,6 +151,12 @@
140 151 if [ "$SKIP_BIB" -eq 1 ]; then
141 152 :
142 153 elif [ "$SKIP_BUILD" -eq 0 ]; then
154 + # Here rather than at the top: --write-only and --skip-build both reach this
155 + # point without minting anything, and a gate that refused to write a disk
156 + # because a recipe had a finding would be gating the wrong act. build-image.sh
157 + # builds disk images for this machine and reads no ARCH, so the gate always
158 + # sees the host's own architecture and runs in full.
159 + preflight_gate "$HOST" "$HOST_ARCH" "$NO_PREFLIGHT"
143 160 echo "==> Building $IMAGE (rootful)"
144 161 # --jobs 2 to overlap the two stages; see the same call in build/build-iso.sh
145 162 # for why two and not more.
@@ -24,6 +24,7 @@
24 24 # build/build-iso.sh --skip-source # omit the OCI layout (faster; the
25 25 # # ISO boots but cannot install)
26 26 # build/build-iso.sh --fast # iteration: cheap compression, keeps source
27 + # build/build-iso.sh --no-preflight # mint a knowingly incomplete image
27 28 # build/build-iso.sh --fast --skip-source # boot chain only, cannot install
28 29 # build/build-iso.sh --update-target host:5000/alloy:43 # updates come from there
29 30 # # (or UPDATE_TARGET in the host recipe)
@@ -56,6 +57,12 @@
56 57 # shellcheck source=build/host-recipe.sh
57 58 . "$REPO_ROOT/build/host-recipe.sh"
58 59
60 + # preflight_gate. Runs build/preflight.sh ahead of the mint and decides what a
61 + # finding means; see the header of build/preflight-gate.sh for which findings
62 + # refuse a build and which only warn.
63 + # shellcheck source=build/preflight-gate.sh
64 + . "$REPO_ROOT/build/preflight-gate.sh"
65 +
59 66 IMAGE="localhost/alloy:local"
60 67 BUILDER="localhost/alloy-iso-builder:local"
61 68 OUTPUT="$REPO_ROOT/output"
@@ -89,6 +96,8 @@
89 96 # Empty means the recipe decides, and a recipe that says nothing means no. See
90 97 # --nvidia below and the NVIDIA key in build/host-recipe.sh.
91 98 NVIDIA=""
99 + # The preflight runs ahead of every mint that has a recipe. See --no-preflight.
100 + NO_PREFLIGHT=0
92 101
93 102 BUILD_ARGS=()
94 103
@@ -108,6 +117,11 @@
108 117 # sets the medium's architecture unless --arch says otherwise.
109 118 --host) HOST="${2:?--host needs a machine name}"; shift 2 ;;
110 119 --skip-source) SKIP_SOURCE=1; shift ;;
120 + # Mint without running build/preflight.sh first. The case it exists for is
121 + # deliberately building a known-incomplete image -- bisecting a Containerfile
122 + # change, or reproducing a defect the preflight would refuse. It is not a way
123 + # past a finding you would rather not read.
124 + --no-preflight) NO_PREFLIGHT=1; shift ;;
111 125 # Iteration mode: reuse the image and compress cheaply, but still carry
112 126 # the install source. Measured on fw13, 2026-08-15: level 19 costs 192s
113 127 # against level 3's 8s, and the export costs 22s and is now skipped
@@ -178,7 +192,12 @@
178 192 command -v podman >/dev/null || die "podman not found"
179 193
180 194 # 1. The Alloy image.
195 + #
196 + # The preflight goes here rather than at the top: --write-only and --skip-build
197 + # both reach this point without minting anything, and a gate that refused to
198 + # write a stick because a recipe had a finding would be gating the wrong act.
181 199 if [ "$SKIP_BUILD" -eq 0 ]; then
200 + preflight_gate "$HOST" "$ARCH" "$NO_PREFLIGHT"
182 201 say "building $IMAGE"
183 202 # --jobs 2 because the Containerfile is two stages that meet only at a COPY.
184 203 # The rust-build stage compiles the console and the terminal (about four
@@ -3,6 +3,16 @@
3 3 # preflight.sh — answer, in seconds, the questions a mint would answer in minutes.
4 4 #
5 5 # build/preflight.sh fw13 # check fw13's recipe before building it
6 + # build/preflight.sh fw13 --fast # the instant checks only, no probe
7 + #
8 + # Exit codes, and the split matters because both builders gate on it:
9 + #
10 + # 0 clean
11 + # 1 findings about the image; do not build
12 + # 2 usage: no host named, or no recipe by that name
13 + # 4 findings about this host alone; the medium is fine
14 + #
15 + # See the comment above the exit at the bottom for why those are two codes.
6 16 #
7 17 # WHY THIS EXISTS. Every assertion Alloy has lives inside the Containerfile, so
8 18 # running one costs a build up to that step, and `bootc container lint` is step
@@ -55,7 +65,14 @@
55 65 # run0 without --pipe puts its child on a pty, which staples a carriage return to
56 66 # every line. Not `exec`, because privc is a shell function.
57 67 out="$(mktemp)"; probe=""
58 - cleanup() { rm -f "$out"; [ -n "$probe" ] && rm -rf "$probe"; }
68 + # The `|| :` is load-bearing, and its absence was a bug this script carried from
69 + # the start. bash lets an EXIT trap set the script's exit status, and
70 + # `[ -n "$probe" ] && rm -rf "$probe"` ends on a false test whenever the probe
71 + # was never built -- which is every --fast run. So a clean --fast run printed
72 + # "preflight: clean" and exited 1. Invisible while nothing read the code, and
73 + # fatal the moment the builders started gating on it: --fast is exactly what a
74 + # cross-architecture mint runs, so astra's every mint would have been refused.
75 + cleanup() { rm -f "$out"; [ -n "$probe" ] && rm -rf "$probe"; :; }
59 76 trap cleanup EXIT
60 77
61 78 # `set -e` with `pipefail` would end the script the moment the resolver reports a
@@ -90,7 +107,15 @@
90 107 # copies etc/ and usr/ and runs its own steps, and any of those could leave a
91 108 # file the probe never carries. The class it does catch is the one that has
92 109 # actually bitten -- a package's %post leaving content behind.
93 - if [ "${2:-}" = "--fast" ]; then
110 + if [ "$rc" -ne 0 ]; then
111 + # The dials failed, so the recipe describes an image the Containerfile's own
112 + # validator refuses. Everything the probe would measure is a property of a
113 + # mint that cannot happen, so building it costs a dnf transaction to answer
114 + # questions about nothing. It also makes the gate slow precisely when it is
115 + # about to refuse, and a gate people wait on is a gate people route around.
116 + echo
117 + echo "== probe: skipped; the dials above are already a refusal"
118 + elif [ "${2:-}" = "--fast" ]; then
94 119 echo
95 120 echo "== probe: skipped (--fast); dials and guards only"
96 121 else
@@ -237,9 +262,25 @@
237 262 fi
238 263 done
239 264
265 + # Two kinds of finding, and they are not the same kind of problem, so they no
266 + # longer share an exit code.
267 + #
268 + # dials, requires and var-payload are about the medium: an image built with one
269 + # of them outstanding is wrong wherever it is built, and a mint should not
270 + # proceed. guards is about the machine you are standing on -- it reads sibling
271 + # repos under ~/Code that no image contains -- so a stale goingson clone predicts
272 + # a failed AppImage on this host and says nothing about the medium being minted.
273 + #
274 + # Collapsed into one code, the script could not be used as a gate at all: it
275 + # would refuse to build a perfectly good ISO because a repo next door was
276 + # behind. Separated, a caller can act on each. build/preflight-gate.sh does.
240 277 if [ "$rc" -eq 0 ] && [ "$guards_bad" -eq 0 ]; then
241 278 echo; echo "preflight: clean"
242 279 exit 0
243 280 fi
281 + if [ "$rc" -eq 0 ]; then
282 + echo; echo "preflight: this host has findings; the medium itself is clean"
283 + exit 4
284 + fi
244 285 echo; echo "preflight: findings above; fix them before building"
245 286 exit 1
M docs/IMAGE.md +43 -1
@@ -312,7 +312,8 @@
312 312 ## Preflight
313 313
314 314 `build/preflight.sh <host>` answers, before any mint, what a mint would otherwise
315 - answer slowly. Run it whenever a recipe or the package set changes:
315 + answer slowly. **Both builders run it themselves**, so this is the explicit form
316 + rather than the usual one:
316 317
317 318 ```sh
318 319 build/preflight.sh fw13 # everything
@@ -344,6 +345,47 @@
344 345 what the script exists to avoid, but it is one dnf transaction rather than 103
345 346 steps, and it is right rather than close.
346 347
348 + ### The gate
349 +
350 + `build/build-iso.sh` and `build/build-image.sh` both run the preflight before
351 + they mint anything, and refuse on a finding. `build/preflight-gate.sh` holds that
352 + policy once, so the two builders cannot drift apart on it.
353 +
354 + It was deliberately not a gate on day one: a gate that fires wrongly on its first
355 + day is worse than no gate, and the preflight had never been run in anger. It has
356 + since caught four things nobody predicted, including a recipe combination the
357 + Containerfile's validator refuses on a machine whose medium had never been built.
358 +
359 + **Only findings about the medium refuse a build.** `dials`, `requires` and
360 + `var-payload` describe the image, and an image with one of them outstanding is
361 + wrong wherever it is built. `guards` describes the machine you are standing on --
362 + it reads sibling repos under `~/Code` that no image contains -- so a stale
363 + goingson clone predicts a failed AppImage here and says nothing about the medium.
364 + That warns loudly and the build goes on. Refusing a mint because a repo next door
365 + is behind would make the gate a thing people route around, and routing around it
366 + is how the four findings above would have been missed.
367 +
368 + The two kinds are two exit codes, which is what makes the split possible:
369 +
370 + | code | means |
371 + |---|---|
372 + | 0 | clean |
373 + | 1 | findings about the image; do not build |
374 + | 2 | usage: no host named, or no recipe by that name |
375 + | 4 | findings about this host alone; the medium is fine |
376 +
377 + **How much of the preflight a mint runs** is decided the same way. The probe is
378 + one dnf transaction: seconds on the host's own architecture, minutes under
379 + emulation (measured 2026-09-04, the astra probe took minutes on fw13 and seconds
380 + on astra). So the gate runs the full preflight when the medium's architecture is
381 + this machine's and `--fast` when it is not, and says which it chose. A mint with
382 + no `--host` is not gated, because there is no recipe to check; that is printed
383 + rather than silent.
384 +
385 + `--no-preflight` skips it, for deliberately building a known-incomplete image --
386 + bisecting a Containerfile change, or reproducing a defect the preflight would
387 + refuse. It is not a way past a finding you would rather not read.
388 +
347 389 **What the preflight cannot see**, stated so a clean run is not read as more than
348 390 it is:
349 391
@@ -1,0 +1,96 @@
1 + #!/usr/bin/env bash
2 + #
3 + # preflight-gate.sh — run build/preflight.sh ahead of a mint, and decide what a
4 + # finding means. Sourced by build/build-image.sh and build/build-iso.sh, so the
5 + # policy exists once rather than in two places that drift.
6 + #
7 + # WHY THIS IS A GATE AND WAS NOT ONE ON DAY ONE. A gate that fires wrongly on
8 + # its first day is worse than no gate, and the preflight had never been run in
9 + # anger. It has since caught four things nobody predicted, one of them a recipe
10 + # combination the Containerfile's own validator refuses, so the condition is met.
11 + #
12 + # WHAT REFUSES A BUILD, and this is the decision the gate exists to hold.
13 + # preflight answers two different kinds of question, and only one of them is
14 + # about the thing being built:
15 + #
16 + # dials, requires, var-payload about the medium. An image with one of these
17 + # outstanding is wrong wherever it is built.
18 + # These refuse.
19 + # guards about this machine. It reads sibling repos
20 + # under ~/Code that no image contains, so it
21 + # predicts a failed AppImage on this host and
22 + # says nothing about the medium. This warns.
23 + #
24 + # Refusing a mint because a repo next door is stale would make the gate a thing
25 + # people route around, and routing around it is how the four findings above
26 + # would have been missed. So the warning is loud and the build proceeds; the
27 + # finding is still on the screen and still worth fixing before a release.
28 + #
29 + # HOW MUCH PREFLIGHT RUNS, which is the other half of the decision. The probe is
30 + # one dnf transaction: seconds on the host's own architecture, minutes under
31 + # emulation. Measured 2026-09-04, the astra probe took minutes on fw13 and
32 + # seconds on astra. A gate that always builds it makes every cross-architecture
33 + # mint slow to start, for an answer emulation is the worst way to get. So the
34 + # gate runs the full preflight when the mint's architecture is this machine's,
35 + # and --fast when it is not, saying which it chose and why.
36 + #
37 + # Usage: preflight_gate HOST ARCH SKIP
38 + # HOST the recipe name, empty for a mint with no recipe
39 + # ARCH the medium's architecture, empty for this machine's
40 + # SKIP 1 to skip the gate entirely (--no-preflight)
41 + #
42 + # Returns 0 to proceed. On a finding about the image it does not return: it
43 + # exits the calling script, because a caller that had to remember to check would
44 + # eventually forget.
45 +
46 + preflight_gate() {
47 + local host="$1" arch="${2:-}" skip="${3:-0}"
48 + local rc=0 mode=()
49 +
50 + if [ "$skip" = "1" ]; then
51 + printf '==> preflight: skipped (--no-preflight)\n'
52 + return 0
53 + fi
54 +
55 + # No recipe, nothing to check. preflight reads build/hosts/<name>.env and has
56 + # nothing to say about a mint that takes the Containerfile's own defaults.
57 + # Stated rather than silent, so a hostless mint does not read as a gated one.
58 + if [ -z "$host" ]; then
59 + printf '==> preflight: skipped (no --host, so there is no recipe to check)\n'
60 + return 0
61 + fi
62 +
63 + if [ -n "$arch" ] && [ "$arch" != "$(uname -m)" ]; then
64 + mode=(--fast)
65 + printf '==> preflight %s --fast (minting %s on %s; the probe would be emulated)\n' \
66 + "$host" "$arch" "$(uname -m)"
67 + else
68 + printf '==> preflight %s\n' "$host"
69 + fi
70 +
71 + "$REPO_ROOT/build/preflight.sh" "$host" "${mode[@]}" || rc=$?
72 +
73 + case "$rc" in
74 + 0) return 0 ;;
75 + 4)
76 + printf '\n'
77 + printf '==> preflight: this HOST has findings; the medium is clean, so the build goes on.\n'
78 + printf ' Fix them before you cut a release from this machine.\n\n'
79 + return 0
80 + ;;
81 + 2)
82 + # Usage, not a finding: --host named a recipe preflight could not read.
83 + # Saying "findings" here would send someone looking for a defect in an
84 + # image that was never described.
85 + printf '\n'
86 + printf 'error: preflight could not read a recipe for %s. Not building.\n' "$host" >&2
87 + exit 2
88 + ;;
89 + *)
90 + printf '\n'
91 + printf 'error: preflight found something the mint would carry. Not building.\n' >&2
92 + printf ' Pass --no-preflight to mint anyway, for a deliberately incomplete image.\n' >&2
93 + exit "$rc"
94 + ;;
95 + esac
96 + }