Skip to main content

max / alloy

292.8 KB · 5064 lines History Blame Raw
1 # Alloy — bootable container image
2 #
3 # Built with `podman build`. Nothing is published: distribution is the builder
4 # and not the artifact, so `quay.io/alloy/alloy` is not a future address either
5 # (decided 2026-07-30; docs/STACK.md#distribution, docs/IMAGE.md).
6 # See docs/IMAGE.md for the composition strategy and rationale.
7 #
8 # Layer order optimizes rebuild speed: repos first (rarely change),
9 # then fonts (large downloads, pinned), then package installs (change
10 # with STACK.md), then config tree (changes most often, so lives at
11 # the tail).
12 #
13 # Two stages. Everything the image runs ships from a repo except the
14 # Alloy console, which is this project's own binary and has no repo to
15 # ship from — the one case docs/IMAGE.md's stage policy allows. Screen
16 # recording is still a gap (docs/STACK.md#screen-recorder).
17
18 # =====================================================================
19 # Build stage — the Alloy console.
20 # =====================================================================
21 # Fedora 43 rather than a rust: image or the host, so the toolchain and
22 # the glibc the console links against are the ones it will run on. This
23 # is also why the console is not simply built on fw13 and copied in:
24 # fw13 is Pop!_OS 24.04, a different libc, and per CLAUDE.md builds are
25 # native per architecture rather than cross-compiled.
26 #
27 # Cargo.toml pins edition 2024 and rust-version 1.86; Fedora 43 clears
28 # both. `--locked` so the image builds the dependency graph the repo
29 # committed rather than whatever resolves that day.
30 #
31 # Pinned by digest, and the tag is kept alongside it for readability only —
32 # the digest is what resolves. `:43` is a floating tag: it moves on every
33 # Fedora respin, so two builds of the same alloy commit a week apart used to
34 # build against different toolchains and different glibc. That is exactly the
35 # drift the fonts below are pinned to avoid, and the font comment's argument
36 # ("a cache hit stops meaning the same bytes") was never carried to `FROM`
37 # until now. See wiki `alloy-distribution`, "Rebuilding a past image".
38 #
39 # This is the multi-arch index digest, not a per-arch manifest, so it still
40 # resolves natively on both build hosts (fw13 amd64, astra arm64) — CLAUDE.md
41 # forbids cross-compiling, so a per-arch pin would break one of them.
42 #
43 # To move it: build/refresh-base-digests.sh, which resolves the tags and
44 # rewrites these two lines. Do that deliberately, not as drive-by maintenance.
45 FROM registry.fedoraproject.org/fedora:43@sha256:4432141107de445eae7bcb3b5b9d29d39136109cff407bcaf517109c84f789ef AS rust-build
46
47 # No python3 any more. It was here for tools/vtrgb.py, which rendered the
48 # greeter's console palette; skelgen emits that table now, along with the rest
49 # of the desktop skeleton, so there is one implementation of the ANSI mapping
50 # instead of three.
51 # git, and the three libraries shop links, on top of the toolchain. The console
52 # needs none of them; shop is Wayland and GPU facing, so it links
53 # libwayland-client and libxkbcommon at build time and will not compile
54 # without their headers. `ldd` on the built binary is the check if this list
55 # ever looks wrong.
56 #
57 # fontconfig-devel is the third, and it arrived with the terminal rather than
58 # with anything here: shop asks the system which font carries a glyph the
59 # bundled Iosevka lacks (CJK, hangul), and the crate behind that question links
60 # libfontconfig through pkg-config rather than dlopening it. So a SHOP_REV move
61 # can add a build dependency, which is what happened at shop@53551eb and broke
62 # both profiles at this step. The runtime already carries fontconfig for the
63 # fonts layer, so only the headers were ever missing.
64 #
65 # rpm-build and createrepo_c are the last two, and they are not build tools for
66 # either binary. This stage packages both into RPMs at the end (see "The
67 # component repo" below), because the runtime image carries our components as
68 # uninstalled packages rather than as files. A stage of its own would want a
69 # third digest-pinned FROM, and build/refresh-base-digests.sh rewrites exactly
70 # two.
71 RUN dnf install -y cargo rust git wayland-devel libxkbcommon-devel \
72 fontconfig-devel pkgconf rpm-build createrepo_c \
73 && dnf clean all
74
75 # How many rustc jobs, taken from memory rather than from core count.
76 #
77 # cargo sizes `-j` by cores alone, and a heavy crate costs rustc around 3 GB.
78 # fw13 is 12 cores against 14 GB, so the default aims twelve of those at
79 # fourteen gigabytes and the machine pages instead of compiling: measured
80 # across ~/Code on 2026-08-11, two concurrent clippy-drivers at 3.0 GB each
81 # with 460 MB free and a quarter to a third of wall-clock stalled on paging.
82 # The tree's own ~/.cargo config caps it at six for that reason, and that file
83 # is outside the build context and cannot reach in here.
84 #
85 # So the rule is derived rather than copied: this Containerfile is what
86 # strangers build Alloy with, and their boxes are not fw13. Half the gigabytes,
87 # never more than the cores, never less than one.
88 #
89 # It matters more since the two stages build concurrently (`--jobs 2` in
90 # build/build-iso.sh and build/build-image.sh): the package installs of the
91 # runtime stage now overlap this one's compiles, and paging here would hand
92 # back exactly what the overlap was worth.
93 RUN set -eu; \
94 gb=$(awk '/MemTotal/ {print int($2 / 1024 / 1024)}' /proc/meminfo); \
95 jobs=$(( gb / 2 )); \
96 [ "$jobs" -ge 1 ] || jobs=1; \
97 [ "$jobs" -le "$(nproc)" ] || jobs=$(nproc); \
98 mkdir -p /root/.cargo; \
99 printf '[build]\njobs = %d\n' "$jobs" > /root/.cargo/config.toml; \
100 echo "cargo: $jobs jobs ($(nproc) cores, ${gb} GB)"
101
102 # =====================================================================
103 # shop, Alloy's terminal.
104 # =====================================================================
105 # Built from source because nothing serves a prebuilt shop package. shop is
106 # packaged — `build/rpm/shop.spec` is written and the RPM stage 600 lines below
107 # cuts it from the binary this stage produces — but the spec has no `%build`,
108 # so this stage is still the only definition of how shop is compiled and the
109 # only place that compiles it.
110 #
111 # THIS ARRANGEMENT HAS A STATED END: GoingsOn alloy `a29eccdf`. When something
112 # else cuts shop's package, this stage installs it and the terminal stops being
113 # compiled here. Everything below that is scaffolding for building from a git
114 # URL retires with it, and that comment names the list. It is blocked on who
115 # builds the package and on which arches (`f3544f34`), and on somewhere to
116 # serve it from (`86cb87b9`). Until then the scaffolding stays and is load
117 # bearing; do not thin it out on the grounds that it is temporary.
118 #
119 # Builders-not-images is untouched by any of that: users still build their own
120 # ISO. What a served repo buys is that they stop having to rebuild it to
121 # receive a fix (GoingsOn `d866e125`).
122 #
123 # The consequence for this stage, and it holds either way: a component the base
124 # image carries cannot be replaced client-side, so a hotfixable component must
125 # NOT be built into the image at all. It is layered at install time instead.
126 # See build/layertest/README.md for the measurements that force this, and note
127 # that a `COPY` into /usr/bin is the worst of the shapes — an unowned file
128 # cannot even be layered over, it dies in checkout.
129 #
130 # The mirror of quasi-type's pinned bases, named here rather than in the font
131 # stage below because the fetch it exists for is `cargo install shop`, 180 lines
132 # further down. shop's `shop-font/build.rs` cuts a third face from the same
133 # Atkinson base with the cache directory and the offline flag both hardcoded, so
134 # the seed cache the font stage carries has no path and no flag to aim at. A
135 # mirror is the only one of the available fixes that covers that fetch without
136 # shop changing, which is why it is an ENV over the whole stage rather than a
137 # flag on the cut.
138 #
139 # A base URL. quasi-type addresses every pinned file under it by the sha256 the
140 # pin already carries, tries it before upstream, and verifies the bytes against
141 # that digest either way, so a mirror can serve the pinned file or nothing. It
142 # cannot serve a different one, which is what makes this a second source rather
143 # than a second thing to trust. Bytes that do not match fall through to the
144 # pinned url rather than failing the build, so an out-of-date mirror is not
145 # worse than no mirror.
146 #
147 # Set it empty to switch it off and fetch upstream only, which is what a build
148 # with no access to our infrastructure wants. Nothing requires it to answer: the
149 # font stage reports which pinned files it holds rather than asserting any, since
150 # a mirror that is down is a slower build and not a broken one.
151 #
152 # Needs a QUASI_TYPE_REV at or after 43845e3, where the variable starts being
153 # read, and a SHOP_REV whose own quasi-type pin is at or after it. All three move
154 # together for that reason. The files are in the MNW server's `static/bases/`,
155 # served by the `/static` ServeDir with no route of its own.
156 ARG QUASI_TYPE_MIRROR=https://makenot.work/static/bases
157 ENV QUASI_TYPE_MIRROR=${QUASI_TYPE_MIRROR}
158
159 # Pinned by revision, not by branch. An unpinned build is a different image
160 # every day for the same Containerfile, and the terminal is the one component
161 # where "it worked yesterday" has no recovery path from inside the session.
162 # Bump this deliberately, and run `build/check-rust-stage.sh` before pushing
163 # the bump: shop links its system libraries rather than dlopening them, so a
164 # revision can arrive needing a -devel package the dnf line above does not
165 # install, and this file is where that has to be fixed. On 2026-08-09 nobody
166 # ran it and the break reached three remotes.
167 #
168 # That sentence used to end there, as an instruction. It is a gate now: a clean
169 # run records the revision in `build/rust-stage-verified`, and
170 # `crates/alloy/tests/shop_rev_verified.rs` fails while this line names a
171 # different one, so an unchecked bump is refused by `cargo test` rather than by
172 # an hour of podman. The astra sweep still runs the script nightly
173 # (`rust-stage`), which is the backstop for a pinned revision that stops
174 # building without anyone editing this file.
175 #
176 # `cargo install` rather than a second COPY-and-build stage: shop is a separate
177 # repo with its own workspace, so there is no local tree to copy and no
178 # existing pin to imitate. It used to carry `--locked` as the console's build
179 # below does; see the comment on the install itself for why it cannot any more.
180 #
181 # Above the console's source on purpose. This layer is keyed on the revision
182 # alone, so console edits — the thing that changes most often — reuse it
183 # instead of rebuilding a terminal that did not change. Moving it below the
184 # `COPY crates/` would defeat the dependency-cache split that follows.
185 ARG SHOP_REV=144438245fa3b25b897f4c61ae88f49b3508f66a
186
187 # Does the remote have anything to build from, before anything expensive runs.
188 #
189 # A guard against a state this image was actually in for several days. shop was
190 # registered and listed on makenot.work but had never received a push, because
191 # of the mnw-cli receive-pack deadlock, so the URL served zero refs and every
192 # build died at this step. `cargo install` said `revspec '07fb4e70...' not
193 # found`, which reads as a bad SHA and sends you to check the revision. The
194 # revision was fine. The remote was empty. Fixed since: the push landed and the
195 # remote carries main.
196 #
197 # An empty repo is not an error to git. `ls-remote` on one exits 0 and prints
198 # nothing, which is why nothing downstream caught it, while a genuinely absent
199 # repo fails loudly on its own. So counting refs is the check that separates
200 # "published" from merely "named", and it is cheap enough to keep forever.
201 #
202 # What this does NOT prove is that ${SHOP_REV} is in the remote. Reachability of
203 # an arbitrary commit is not a question the wire protocol answers: ls-remote
204 # lists ref tips, a pin is allowed to point below one, and makenot.work refuses
205 # unadvertised objects, so a direct fetch of a non-tip rev fails even when the
206 # commit is present. The pin is normally an ancestor of main rather than its
207 # tip, which is the ordinary state of a deliberate pin and not a fault. So a
208 # non-tip rev is reported and left to cargo, which clones and resolves it from
209 # history, rather than failed here on evidence that cannot support it.
210 RUN set -eu; \
211 url=https://makenot.work/git/max/shop.git; \
212 refs=$(git ls-remote "$url" 2>/dev/null) \
213 || { echo "cannot reach $url to check SHOP_REV" >&2; exit 1; }; \
214 [ -n "$refs" ] \
215 || { echo "$url serves zero refs, so no revision can resolve. shop is named on the server but nothing has been pushed to it. See GoingsOn alloy problem 49732815 for the last time this happened." >&2; exit 1; }; \
216 if echo "$refs" | grep -q "^${SHOP_REV}"; then \
217 echo "SHOP_REV ${SHOP_REV} is a ref tip on the remote"; \
218 else \
219 echo "SHOP_REV ${SHOP_REV} is not a ref tip; it may still be reachable, leaving it to cargo"; \
220 fi
221
222 # `--locked` came off on 2026-08-17, and the reason is a property of the tree
223 # rather than of shop. shop now takes an in-house git dependency (`quasi-type`,
224 # which cuts its bundled face), and every machine in that tree redirects in-house
225 # git URLs to working copies through `~/Code/.cargo/config.toml`'s `[patch]`
226 # block. Cargo writes a patched package into the lock with no `source` line, so
227 # the committed lock names a package a fresh clone cannot resolve and `--locked`
228 # refuses to update it: measured, `cargo metadata --locked` on a clean clone of
229 # shop fails outright. The tree's own note says as much — `--locked` is off the
230 # table for as long as that block exists.
231 #
232 # What replaces the guarantee, for the part that matters here: shop pins
233 # `quasi-type` by revision in its own manifest, so the face it bundles is a
234 # pinned cut rather than whatever resolved that day, and it is the same cut this
235 # image installs. Everything else in shop's graph is a crates.io semver
236 # requirement, which is the same latitude every other build in the tree takes.
237 RUN cargo install \
238 --git https://makenot.work/git/max/shop.git \
239 --rev "${SHOP_REV}" \
240 --root /shop \
241 shop
242
243 # The binary exists and runs. Every other way this fails is silent at build
244 # time and total at first login: sway binds $mod+Return to `shop` and the
245 # launcher spawns `${TERMINAL:-shop}`, so a missing binary is a session with
246 # no way to reach a shell and no way to edit the config that would fix it,
247 # short of a VT. Same reasoning as the console's stub assertions below.
248 RUN test -x /shop/bin/shop \
249 || { echo "shop did not install; the session would have no terminal" >&2; exit 1; }
250
251 # =====================================================================
252 # The house faces, cut rather than downloaded.
253 #
254 # Quasi Mono and Quasi Body are a pinned base plus the house glyph set, run
255 # through `quasi-type` (wiki `typography-standard`). They replaced a 60 MB
256 # IosevkaTerm Nerd Font download and an upstream Atkinson tarball, and the
257 # marks in them are the same drawings every other Alloy surface uses instead
258 # of whatever each renderer's fallback ordered first.
259 #
260 # Cut here rather than fetched because there is nothing to fetch: built faces
261 # are deliberately not committed anywhere, since a font in a repo is a second
262 # source of truth that nothing rebuilds. The pipeline pins its own bases by
263 # sha256, so this layer is reproducible for the same reason the download it
264 # replaced was.
265 #
266 # A clone and `cargo run` rather than `cargo install`: the tool reads its pins
267 # and its cache out of the checkout it is run from, so an installed binary has
268 # nowhere to put a downloaded base.
269 #
270 # Above the console's source for the same reason shop is: keyed on a revision
271 # that changes rarely, so a console edit does not recut two fonts.
272 #
273 # THE PART THAT IS NOT REPRODUCIBLE IS AVAILABILITY, and it took the build down
274 # on 2026-08-17. The pins name files on raw.githubusercontent.com, which
275 # rate-limits by IP, and one clean build asks it three times: the two cuts below
276 # and shop's own build script, which cuts a third face into its OUT_DIR. The
277 # host answered 429 and the build died at `cargo install shop`, an hour in,
278 # behind every expensive layer. A sha256 makes the bytes the same bytes; it says
279 # nothing about whether they arrive. "Build it yourself" is Alloy's only install
280 # path (wiki `alloy-distribution`), so a fetch that fails is an install that
281 # cannot happen, and that makes this a property of the distribution rather than
282 # a flaky step.
283 #
284 # WHAT THE SEED COVERS IS STILL THIS BLOCK'S TWO CUTS, AND NOTHING ELSE. Say it
285 # here rather than let a reader infer a whole-build guarantee from the word
286 # `sealed`. `cargo install shop` is 140 lines above, and moving the COPY above it
287 # would change nothing: shop's `shop-font/build.rs` calls
288 # `quasi_type::cut_native(&out, &out.join("bases"), false, ...)`, with the cache
289 # directory hardcoded to its own OUT_DIR and `offline` hardcoded false, so there
290 # is no path and no flag for this file to aim at a carried copy. Read from shop
291 # at rev ${SHOP_REV} on 2026-08-29.
292 #
293 # WHAT COVERS THE FETCH THE SEED DOES NOT COVER IS THE MIRROR, and that is why
294 # the mirror is the fix this stage took. `QUASI_TYPE_MIRROR` is read inside
295 # quasi-type, which is the crate on both sides of the hardcoding, so it reaches
296 # a fetch this file cannot otherwise touch. The seed still does not cover that
297 # fetch: a mirror is a better source and not a carried copy, so `sealed` means
298 # exactly what it meant.
299 #
300 # THE CONTRACT SHOP WOULD HAVE TO IMPLEMENT, if `sealed` is ever to be a claim
301 # about the build and not only about the cut: read the cache directory from an
302 # environment variable, falling back to `OUT_DIR/bases` when it is unset so
303 # nothing about a plain `cargo build` changes, and take `offline` from a second
304 # one. Two lines in that build script. When they exist, the COPY moves above
305 # `cargo install shop` and this stage exports both variables. It is shop's change
306 # to make and it is not made here; the mirror lowered its urgency rather than
307 # removing it, since an offline build host still cannot seal that fetch.
308 #
309 # TWO MORE THINGS STAND AGAINST THE OTHER TWO FETCHES, and neither is the durable
310 # fix the mirror is.
311 #
312 # 1. The retry is upstream, in quasi-type's own fetch: curl `--retry 5
313 # --retry-delay 2 --retry-all-errors`, which covers 429, 408, the 5xx
314 # family and the connection failures a shared network produces. It is in
315 # the pinned revision above, so it is what this build already gets, and it
316 # is what shop's fetch gets too, since that is the same code.
317 # 2. The seed below, which is what a build can carry rather than ask for.
318 # `/base-cache` is copied from `build/base-cache/` in the context, so the
319 # supported way to use it is to put the files there; both wrapper scripts
320 # forward `--build-arg` and nothing else, so the `podman build -v
321 # <dir>:/base-cache:ro` route is a bare `podman build` away from them, and
322 # that produces an image with no build stamp (docs/IMAGE.md). Seeded files
323 # are copied into the checkout's own `bases/cache/`, which is the directory
324 # quasi-type reads before it fetches anything. Seeding cannot forge a base:
325 # every cached file is verified against the pin's sha256 exactly as a
326 # downloaded one is, so a wrong or tampered seed fails the cut rather than
327 # shipping.
328 #
329 # `QUASI_BASES=sealed` turns the seed into a guarantee about the cut. It passes
330 # `--offline`, so a base the seed does not carry fails here rather than reaching
331 # for a host that may not answer. It does not make the build offline: the clone
332 # above and `cargo build` below still go out, and so does shop's own fetch. Use
333 # it on a build host with a warm cache; `fetch`, the default, is what a stranger
334 # cloning the repo gets and still works with an empty seed.
335 #
336 # THE SEALED GATE READS THE PINS RATHER THAN COUNTING FILES. A seed that is
337 # present but wrong is the likely mistake, since the cache names are long and
338 # percent-encoded, and a count cannot tell a complete seed from one file. So the
339 # names are derived from the checkout's own `bases/pins.toml`, which is the same
340 # file the tool reads: base id, base version and the leaf of each pinned url,
341 # joined the way `Face::cache_name` joins them, plus the `.zip` an archive-pinned
342 # base caches and the `-LICENSE.txt` a `license_url` caches.
343 #
344 # THE ARCHIVE BRANCH NAMES THE ZIP AND THE LICENCE BOTH. `license_text()` in
345 # quasi-type caches `<id>-<ver>-LICENSE.txt` for any base declaring a
346 # `license_url`, archive-pinned or not, and reads the licence out of the zip only
347 # when there is no such url. The licence entry therefore follows the url rather
348 # than the shape of the pin, and the archive branch prints it too. Neither base
349 # is archive-pinned today, so the derived list is the same either way; what this
350 # buys is that the seed is not one file short on the day one is.
351 # Derived rather than listed here because a list would be a second copy of the
352 # pin, and it would go stale the first time QUASI_TYPE_REV moved to a base with
353 # a new version in its file names.
354 #
355 # THE COPY ON INFRASTRUCTURE WE OWN is the mirror. The four pinned files live
356 # in the MNW server's `static/bases/` named by their sha256, and
357 # `QUASI_TYPE_MIRROR` near the top of this stage points quasi-type at them. It
358 # is an ENV over the whole stage rather than a flag here because the fetch that
359 # took the build down was shop's, not these two, and shop reads the same
360 # variable through the same crate.
361 #
362 # It adds a source and does not remove one: every base still carries its
363 # upstream url and falls back to it, so a build with no access to makenot.work
364 # is slower on a cold cache and nothing more.
365 #
366 # THE CUT IS UNCONDITIONAL, and that is a decision rather than an omission. Only
367 # the client installs these faces (see the profile block near the end of this
368 # file), so cutting them on `server` looks like waste. What the conditional
369 # would buy is the two cuts on a server build, and not the third fetch: `cargo
370 # install shop` above runs on both profiles, since both carry the terminal's
371 # RPM, and shop's build script cuts its own face from the same Atkinson base.
372 # What it would cost is the property that makes this stage cheap. Nothing in it
373 # reads `$PROFILE`, so both profiles share every layer of it, including the
374 # console build below and the RPM packaging after that.
375 ARG QUASI_TYPE_REV=52c5bc069fa0b0f3f74bad6957e2d5ca12c0b57d
376 ARG QUASI_BASES=fetch
377 COPY build/base-cache/ /base-cache/
378 RUN set -eu; \
379 case "$QUASI_BASES" in \
380 fetch|sealed) ;; \
381 *) echo "unknown QUASI_BASES '$QUASI_BASES'; expected 'fetch' or 'sealed'" >&2; exit 1 ;; \
382 esac; \
383 slots="quasi-mono quasi-body"; \
384 git clone --no-checkout https://makenot.work/git/max/quasi-type.git /quasi-type; \
385 git -C /quasi-type checkout --detach "${QUASI_TYPE_REV}"; \
386 mkdir -p /quasi-type/bases/cache; \
387 seeded=0; \
388 for base in /base-cache/*; do \
389 [ -f "$base" ] || continue; \
390 case "${base##*/}" in *.md) continue ;; esac; \
391 cp "$base" /quasi-type/bases/cache/; \
392 seeded=$((seeded + 1)); \
393 done; \
394 wanted=$(awk -v want="$slots" ' \
395 function leaf(s, n, p) { n = split(s, p, "/"); return p[n] } \
396 function val(v) { sub(/^[^=]*= */, "", v); gsub(/"/, "", v); return v } \
397 $1 == "[[base]]" { nb++; sec = "base"; next } \
398 $1 == "[[base.face]]" { sec = "face"; next } \
399 $1 == "[[slot]]" { ns++; sec = "slot"; next } \
400 sec == "base" && $1 == "id" { bid[nb] = val($0); next } \
401 sec == "base" && $1 == "version" { bver[nb] = val($0); next } \
402 sec == "base" && $1 == "url" { archive[nb] = 1; next } \
403 sec == "base" && $1 == "license_url" { license[nb] = 1; next } \
404 sec == "face" && $1 == "url" { faces[nb] = faces[nb] " " leaf(val($0)); next } \
405 sec == "face" && $1 == "path" { faces[nb] = faces[nb] " " leaf(val($0)); next } \
406 sec == "slot" && $1 == "id" { sid[ns] = val($0); next } \
407 sec == "slot" && $1 == "base" { sbase[ns] = val($0); next } \
408 END { \
409 n = split(want, w, " "); \
410 for (i = 1; i <= n; i++) { \
411 b = 0; \
412 for (s = 1; s <= ns; s++) if (sid[s] == w[i]) for (k = 1; k <= nb; k++) if (bid[k] == sbase[s]) b = k; \
413 if (b == 0) { print "unknown-slot:" w[i]; continue } \
414 if (archive[b]) { print bid[b] "-" bver[b] ".zip"; if (license[b]) print bid[b] "-" bver[b] "-LICENSE.txt"; continue } \
415 m = split(faces[b], f, " "); \
416 for (j = 1; j <= m; j++) print bid[b] "-" bver[b] "-" f[j]; \
417 if (license[b]) print bid[b] "-" bver[b] "-LICENSE.txt"; \
418 } \
419 }' /quasi-type/bases/pins.toml); \
420 [ -n "$wanted" ] \
421 || { echo "derived no base file names from the pins; either bases/pins.toml moved or its shape changed, and the sealed gate cannot check a seed it cannot name" >&2; exit 1; }; \
422 missing=; \
423 for name in $wanted; do \
424 case "$name" in \
425 unknown-slot:*) echo "the pins name no slot '${name#unknown-slot:}'; this stage cuts slots the checkout does not have" >&2; exit 1 ;; \
426 esac; \
427 test -f /quasi-type/bases/cache/"$name" || missing="$missing $name"; \
428 done; \
429 echo "quasi-type bases: seeded $seeded file(s) from /base-cache, mode $QUASI_BASES, missing:${missing:- none}"; \
430 mirror="${QUASI_TYPE_MIRROR:-}"; \
431 if [ -n "$mirror" ] && [ "$QUASI_BASES" = fetch ]; then \
432 have=0; total=0; \
433 for digest in $(sed -n 's/^\(license_\)\{0,1\}sha256 = "\([0-9a-f]\{64\}\)"$/\2/p' /quasi-type/bases/pins.toml); do \
434 total=$((total + 1)); \
435 curl --fail --silent --head --location --max-time 20 "$mirror/$digest" >/dev/null 2>&1 \
436 && have=$((have + 1)); \
437 done; \
438 echo "quasi-type mirror: $mirror holds $have of $total pinned file(s); the rest fall through to their upstream urls"; \
439 else \
440 echo "quasi-type mirror: not consulted (mirror '$mirror', mode $QUASI_BASES)"; \
441 fi; \
442 if [ "$QUASI_BASES" = sealed ]; then \
443 [ -z "$missing" ] \
444 || { echo "QUASI_BASES=sealed and the seed is short of:$missing. The cut would fail on the first of them after building the tool. Copy them into build/base-cache/ (its README says where from) or build with QUASI_BASES=fetch" >&2; exit 1; }; \
445 offline=--offline; \
446 else \
447 offline=; \
448 fi; \
449 cargo build --release --locked --manifest-path /quasi-type/Cargo.toml; \
450 for slot in $slots; do \
451 /quasi-type/target/release/quasi-type build "$slot" --out /faces ${offline}; \
452 done; \
453 rm -rf /quasi-type/target
454
455 # Both faces, and the licence beside them. The cut asserts its own coverage —
456 # every codepoint an Alloy surface emits, checked against the built `cmap` —
457 # so what is left here is that the files arrived.
458 RUN test -f "/faces/QuasiMono[wght].ttf" && test -f "/faces/QuasiBody[wght].ttf" \
459 || { echo "the house faces were not cut; every TUI would draw borders from a fallback" >&2; exit 1; }; \
460 test -f /faces/OFL-QuasiMono.txt && test -f /faces/OFL-QuasiBody.txt \
461 || { echo "the OFL text is missing; the licence has to travel with a modified face" >&2; exit 1; }; \
462 rm -f /faces/*.woff2
463
464 WORKDIR /src
465
466 # The dependency graph first, against a stub main. Without this split
467 # every console edit re-downloads and rebuilds every crate underneath it,
468 # and the console is the part of this image that changes most often.
469 #
470 # EVERY workspace member is named, or cargo cannot resolve the workspace and
471 # the whole split silently degrades to a full rebuild every time. Adding a
472 # crate under crates/ means adding it here as well; there is no glob that
473 # would do it, because a glob would copy the sources this stage exists to
474 # leave behind.
475 COPY Cargo.toml Cargo.lock ./
476 COPY crates/alloy/Cargo.toml crates/alloy/Cargo.toml
477 COPY crates/skelgen/Cargo.toml crates/skelgen/Cargo.toml
478 COPY crates/backdrop/Cargo.toml crates/backdrop/Cargo.toml
479 RUN mkdir -p crates/alloy/src crates/skelgen/src crates/backdrop/src \
480 && echo 'fn main() {}' > crates/alloy/src/main.rs \
481 && echo 'fn main() {}' > crates/skelgen/src/main.rs \
482 && echo 'fn main() {}' > crates/backdrop/src/main.rs \
483 && cargo build --release --locked \
484 && rm -rf target/release/alloy target/release/deps/alloy-* \
485 target/release/.fingerprint/alloy-* \
486 target/release/alloy-skelgen target/release/deps/alloy_skelgen-* \
487 target/release/deps/skelgen-* target/release/.fingerprint/skelgen-* \
488 target/release/alloy-drift target/release/deps/alloy_drift-* \
489 target/release/.fingerprint/alloy-drift-*
490
491 # Then the real source. The removals above are what make cargo rebuild
492 # the binary rather than find the stub's artifact already in place.
493 COPY crates/ crates/
494 RUN cargo build --release --locked
495
496 # Assert the stub is gone rather than trusting the glob that removed it.
497 #
498 # Those `rm` patterns are the only thing standing between this stage and
499 # shipping `fn main() {}` as the console: if cargo ever changes artifact
500 # naming, the glob matches nothing, `rm` still exits 0, cargo finds the unit
501 # fresh, and the stub is uplifted and copied into the image. Every subcommand
502 # would then be a silent no-op, including `alloy install`, which is the ISO's
503 # entire purpose, failing by doing nothing on a machine that was about to be
504 # repartitioned.
505 #
506 # clap's `version` gives the real binary a `--version` that prints its name;
507 # the stub prints nothing and exits 0. That is the whole difference, so that is
508 # what gets checked. The makeover themes glob below is asserted for the same
509 # reason: silently copying nothing rebuilds exactly the failure it fixes.
510 RUN /src/target/release/alloy --version | grep -q '^alloy ' \
511 || { echo "built console is the stub; the cache-split cleanup matched nothing" >&2; exit 1; }
512
513 # skelgen gets the same assertion for the same reason. Its stub would exit 0
514 # having written nothing, and `--help` is the one thing clap gives the real
515 # binary and not `fn main() {}`.
516 RUN /src/target/release/alloy-skelgen --help | grep -q -- '--templates' \
517 || { echo "built skelgen is the stub; the cache-split cleanup matched nothing" >&2; exit 1; }
518
519 # alloy-drift gets the same assertion, and it is the one of the three where the
520 # stub would be hardest to notice: `fn main() {}` on the background layer draws
521 # nothing and exits, shop closes with it, and the desktop falls back to the flat
522 # colour -- which is what a machine whose backdrop was never built looks like
523 # too. `--help` is again the whole difference.
524 RUN /src/target/release/alloy-drift --help | grep -q -- '--pattern' \
525 || { echo "built alloy-drift is the stub; the cache-split cleanup matched nothing" >&2; exit 1; }
526
527 # The one subcommand name that is spelled out in a shell script rather than
528 # resolved by the compiler. usr/bin/alloy-session runs `alloy theme apply` and
529 # swallows its exit code, because nothing before the exec is allowed to fail a
530 # login; the cost of that `|| true` is that renaming the verb makes the wrapper
531 # a no-op and leaves every account on the day render forever, with one line in
532 # the journal per login and no other symptom. clap answers `--help` before any
533 # dispatch, so this asks the binary whether the verb exists without needing a
534 # theme, a home directory, or a skeleton.
535 RUN /src/target/release/alloy theme apply --help >/dev/null \
536 || { echo "the binary has no 'theme apply'; usr/bin/alloy-session would silently do nothing" >&2; exit 1; }
537
538 # The console's themes, staged at a path the runtime stage can name.
539 #
540 # theme.rs has no built-in palette on purpose (docs/TOKENS.md: no hex in
541 # Rust), so a console with no theme file on any search path does not fall
542 # back, it exits. Its last-resort path is makeover's bundled themes, which
543 # resolve through CARGO_MANIFEST_DIR into the registry checkout — a build
544 # stage that gets discarded. Only the binary was copied out, so every
545 # `alloy` subcommand in the image failed with "Theme not found".
546 #
547 # Only the two Akari defaults are taken. makeover ships around thirty,
548 # most of them other people's palettes carrying attribution obligations;
549 # Akari is the one Alloy is entitled to call its own (docs/TOKENS.md).
550 # Widening this means shipping THIRD-PARTY-NOTICES alongside it.
551 #
552 # The glob is asserted rather than trusted: silently copying nothing here
553 # would rebuild exactly the failure this fixes.
554 #
555 # **The version comes from Alloy's own lock**, and `makeover-*` used to stand in
556 # for it. That worked only while exactly one makeover was ever unpacked into the
557 # registry, and it stopped on 2026-08-17: `cargo install shop` resolves shop's
558 # graph, shop takes `makeover = "2.5.0"` and its lock has moved to 2.6.0, while
559 # Alloy is deliberately held at 2.5.1 (GO makeover `85ad7547`, an unresolved
560 # regression in derived emphasis). Two directories, and the glob asserted its way
561 # to a failed build rather than staging the wrong themes — which is the assertion
562 # working, and the reason it is here.
563 #
564 # Naming the version is also the more correct claim on its own terms: what the
565 # console needs staged is the themes of the makeover *it* was compiled against,
566 # not whichever one happens to be on disk.
567 RUN set -eux; \
568 ver=$(awk '/^name = "makeover"$/{f=1;next} f&&/^version = /{gsub(/[";]/,"",$3); print $3; exit}' /src/Cargo.lock); \
569 [ -n "$ver" ] || { echo "alloy's own lock names no makeover, so there is no version to stage" >&2; exit 1; }; \
570 set -- /root/.cargo/registry/src/*/makeover-$ver/themes; \
571 [ "$#" -eq 1 ] && [ -d "$1" ] || { echo "expected one makeover-$ver themes dir, got: $*" >&2; exit 1; }; \
572 mkdir -p /staged-themes; \
573 cp -a "$1/akari-dawn.toml" "$1/akari-night.toml" /staged-themes/
574
575 # The desktop skeleton, rendered from the two staged themes.
576 #
577 # Everything in the image that carries a color — GTK, sway, yazi, helix, rio,
578 # zathura, the greeter's console palette, the kernel's own vt.default_* table —
579 # comes out of this one step. The templates hold structure and token names; the
580 # theme files hold the colors. Nothing here is committed, because it is a pure
581 # function of the two (CLAUDE.md: never store regenerables).
582 #
583 # This replaces about four hundred hex literals that were transcribed by hand
584 # across seventeen files, three of which held their own copy of the ANSI slot
585 # mapping and no two of which agreed.
586 #
587 # skelgen fails the build on an unknown token or an empty render, so the
588 # assertions the vtrgb and theme-glob steps needed are inside it. What has to
589 # stay out here is everything about the shape of the output tree, because a
590 # template tree that silently matched nothing, or a night set that silently went
591 # missing, copies a plausible-looking overlay and says nothing at all.
592 #
593 # Two renders per themed template now, the plain one and a `.night` sibling. The
594 # sibling is written next to its target, which is not where it belongs: left
595 # there, every new user gets a ~/.config/mako/config.night and a
596 # config.night beside every other themed file. No program picks those up —
597 # helix scans for *.toml, sway reads `config` — so the failure is clutter rather
598 # than breakage, which is exactly the kind that ships. Hence the relocation
599 # below, and the assertion that it left nothing behind.
600 #
601 # Every loop is `for x in $(find ...)` rather than `find | while read`, and that
602 # is not a style choice: a piped `while` runs its body in a subshell, so an
603 # `exit 1` inside it exits the subshell and the RUN succeeds anyway. A build
604 # guard that cannot fail the build is worse than no guard. Word splitting is safe
605 # here because no path in templates/ contains whitespace, and the counterpart
606 # assertions below would catch it if one ever did.
607 #
608 # Four guards, none of them a hardcoded number, because the old `-ge 17` floor
609 # went stale the day the night set landed and a floor that passes by accident
610 # reads exactly like a floor that passes on purpose:
611 #
612 # 1. The file count is derived, not written down: one output per template, plus
613 # one more for each template whose first line is a `variants` directive.
614 # Equality rather than a floor, so a render that stops fanning out fails
615 # here, and adding a template needs no edit.
616 # 2. Both vt tables are checked, not only the day one. setvtrgb rejects
617 # anything that is not three rows of sixteen values, and it rejects it at
618 # boot on the greeter, where nobody is reading logs.
619 # 3. Every rendered file under /etc/skel must have a night render, with the two
620 # helix palettes named as the deliberate exceptions: those two files are the
621 # polarities themselves, picked by filename, so a `.night` sibling of either
622 # would be a second copy of one of them under the wrong name. This is the
623 # guard that catches a themed template losing its directive, which guard 1
624 # cannot: guard 1 derives its expectation from the same first lines.
625 # 4. Every night render must have a plain counterpart, which is what
626 # `alloy theme apply`'s pristine test compares against. Together with 3 it
627 # is a bijection, so a stray file on either side fails the build.
628 COPY templates/ /src/templates/
629 RUN set -eux; \
630 /src/target/release/alloy-skelgen \
631 --templates /src/templates \
632 --out /staged-skel \
633 --theme default=/staged-themes/akari-dawn.toml \
634 --theme night=/staged-themes/akari-night.toml; \
635 templates="$(find /src/templates -type f | wc -l)"; \
636 variants="$(find /src/templates -type f -exec awk 'FNR == 1 && /^[[:space:]]*@\{! *variants *=/ { print FILENAME }' {} + | wc -l)"; \
637 rendered="$(find /staged-skel -type f | wc -l)"; \
638 [ "$rendered" -eq "$((templates + variants))" ] \
639 || { echo "skelgen wrote $rendered files; $templates templates of which $variants render twice should have produced $((templates + variants))" >&2; exit 1; }; \
640 for table in /staged-skel/usr/share/alloy/vtrgb /staged-skel/usr/share/alloy/vtrgb.night; do \
641 : "commas, and no whitespace: setvtrgb parses each line comma-delimited"; \
642 : "and rejects anything else before it opens a console. This guard split"; \
643 : "on whitespace until 2026-08-03 and so passed a file setvtrgb refused,"; \
644 : "which is why the palette never once applied on any image."; \
645 awk -F, 'NF != 16 { exit 1 } /[[:space:]]/ { exit 1 } END { if (NR != 3) exit 1 }' "$table" \
646 || { echo "$table is not three rows of sixteen comma-separated values; setvtrgb would reject it" >&2; exit 1; }; \
647 done; \
648 for night in $(find /staged-skel/etc/skel -type f -name '*.night'); do \
649 rel="${night#/staged-skel/etc/skel/}"; \
650 dest="/staged-skel/usr/share/alloy/skel-night/${rel%.night}"; \
651 mkdir -p "$(dirname "$dest")"; \
652 mv "$night" "$dest"; \
653 done; \
654 [ -z "$(find /staged-skel/etc/skel -name '*.night')" ] \
655 || { echo "a night render was left in /etc/skel; every new user would get it" >&2; exit 1; }; \
656 for day in $(find /staged-skel/etc/skel -type f); do \
657 rel="${day#/staged-skel/etc/skel/}"; \
658 case "$rel" in .config/helix/themes/akari-dawn.toml|.config/helix/themes/akari-night.toml) continue;; esac; \
659 [ -f "/staged-skel/usr/share/alloy/skel-night/$rel" ] \
660 || { echo "$rel has no night render; a night session would keep the light one forever" >&2; exit 1; }; \
661 done; \
662 for applied in $(find /staged-skel/usr/share/alloy/skel-night -type f); do \
663 rel="${applied#/staged-skel/usr/share/alloy/skel-night/}"; \
664 [ -f "/staged-skel/etc/skel/$rel" ] \
665 || { echo "night render $rel has no plain counterpart in /etc/skel" >&2; exit 1; }; \
666 done
667
668 # The two skeleton sources must not overlap.
669 #
670 # The runtime stage lays the repo's etc/skel down first and this rendered tree
671 # second, so a path present in both silently resolves to the render and leaves a
672 # committed file in the repo that nothing in the image ever reads. That is how
673 # etc/skel/.config/helix/config.toml would come back: it moved into templates/
674 # to carry `theme = "@{meta.id}"`, and a restored copy would look like the
675 # source of truth while the image used the other one.
676 #
677 # etc/skel is copied in here rather than compared in the runtime stage because
678 # this is the only stage that holds both trees, and this stage is discarded.
679 COPY etc/skel/ /src/etc-skel/
680 RUN set -eux; \
681 for staged in $(find /src/etc-skel -type f); do \
682 rel="${staged#/src/etc-skel/}"; \
683 [ ! -e "/staged-skel/etc/skel/$rel" ] \
684 || { echo "etc/skel/$rel is also rendered from templates/; one of the two is dead" >&2; exit 1; }; \
685 done
686
687 # =====================================================================
688 # The component repo: our two binaries, packaged and never installed.
689 # =====================================================================
690 # The runtime stage does not copy the console and the terminal into /usr/bin.
691 # It carries this repo instead, and alloy-layer-components.service lays both
692 # down as rpm-ostree layers on the first boot after an install.
693 #
694 # The reason is the one thing measured in build/layertest that changes the
695 # design: a component the base image carries cannot be replaced client-side.
696 # An installed base package fails to depsolve against a layer of the same name,
697 # `override replace` records a request that never activates, and a loose file
698 # in /usr/bin — which is what this image shipped until 2026-08-14 — is worse
699 # than either, because layering over an unowned file dies in checkout with
700 # "File exists". Any of the three is a machine whose console can never be
701 # fixed without rebuilding an ISO and writing a drive.
702 #
703 # Carrying the RPMs is not carrying the component, and that distinction is the
704 # whole design. What blocks a layer is an installed package or a file at the
705 # path being layered over; an uninstalled .rpm under /usr/share is neither. So
706 # the packages travel with the image, which means they travel on the ISO, which
707 # means an offline install still produces a working machine and a machine whose
708 # owner has not consented to anything yet never reaches the network for them.
709 #
710 # Packaged here rather than by build/rpm/build.sh, which is the standalone
711 # hotfix path and cannot be called from a build stage. The specs are shared, so
712 # the two produce the same package; `build.sh --component shop --binary` is how
713 # a hotfix is cut from the very binary an image shipped.
714 COPY build/rpm/alloy.spec build/rpm/shop.spec /src/rpm/
715 RUN set -eux; \
716 : "The version comes off each binary, and the console's is cross-checked"; \
717 : "against its crate. An RPM claiming a version the binary inside it does"; \
718 : "not report is invisible afterwards — rpm answers one thing, --version"; \
719 : "another, and --version is what a person quotes in a bug report."; \
720 : ""; \
721 : "head -1 is load-bearing, not caution. Both binaries answer --version"; \
722 : "with a version line followed by copyright and licence lines, so an awk"; \
723 : "over the whole output returns five words rather than one and the"; \
724 : "comparison below fails on a version that was correct all along."; \
725 alloy_version="$(/src/target/release/alloy --version | head -1 | awk '{print $2}')"; \
726 crate_version="$(grep -m1 '^version' /src/crates/alloy/Cargo.toml | cut -d'"' -f2)"; \
727 [ -n "$alloy_version" ] && [ "$alloy_version" = "$crate_version" ] \
728 || { echo "console reports '$alloy_version', crates/alloy/Cargo.toml says '$crate_version'" >&2; exit 1; }; \
729 : "shop is a separate repo pinned by revision, so there is no manifest in"; \
730 : "this context to check it against. It answers --version without opening"; \
731 : "a window, which is the only reason this can ask."; \
732 shop_version="$(/shop/bin/shop --version | head -1 | awk '{print $2}')"; \
733 [ -n "$shop_version" ] \
734 || { echo "shop did not report a version; SHOP_REV may predate it" >&2; exit 1; }; \
735 mkdir -p /rpmbuild/SOURCES /staged-rpm; \
736 install -m 0755 /src/target/release/alloy /rpmbuild/SOURCES/alloy; \
737 install -m 0755 /shop/bin/shop /rpmbuild/SOURCES/shop; \
738 rpmbuild --define '_topdir /rpmbuild' \
739 --define "alloy_version $alloy_version" -bb /src/rpm/alloy.spec; \
740 rpmbuild --define '_topdir /rpmbuild' \
741 --define "shop_version $shop_version" -bb /src/rpm/shop.spec; \
742 cp /rpmbuild/RPMS/*/*.rpm /staged-rpm/; \
743 createrepo_c /staged-rpm; \
744 : "Exactly one package each, and the metadata that makes them a repo. A"; \
745 : "repo missing one is an install that reaches the greeter with no console"; \
746 : "or no terminal behind it. Globbed rather than named: the %{dist} tag and"; \
747 : "the arch come from whichever host is building, and neither is this"; \
748 : "assertion's business — one file, carrying the version asked for, is."; \
749 set -- /staged-rpm/alloy-*.rpm; \
750 [ "$#" -eq 1 ] && [ -f "$1" ] \
751 || { echo "expected one console RPM, got: $*" >&2; exit 1; }; \
752 case "$1" in */alloy-"$alloy_version"-*) ;; \
753 *) echo "console RPM $1 is not version $alloy_version" >&2; exit 1;; esac; \
754 set -- /staged-rpm/shop-*.rpm; \
755 [ "$#" -eq 1 ] && [ -f "$1" ] \
756 || { echo "expected one terminal RPM, got: $*" >&2; exit 1; }; \
757 case "$1" in */shop-"$shop_version"-*) ;; \
758 *) echo "terminal RPM $1 is not version $shop_version" >&2; exit 1;; esac; \
759 [ -f /staged-rpm/repodata/repomd.xml ] \
760 || { echo "createrepo_c wrote no metadata; the carried repo would resolve nothing" >&2; exit 1; }
761
762 # =====================================================================
763 # Runtime image — the bootable container itself.
764 # =====================================================================
765 # Digest-pinned for the same reason as the build stage above, and it matters
766 # more here: this one is the operating system. See that comment for why the
767 # tag is kept beside the digest and why it is the multi-arch index.
768 FROM registry.fedoraproject.org/fedora-bootc:43@sha256:c1133226662ce7f79c2398bf6f86047ff37fd82e843dee4d5f9cb8f3dbb58c8d
769
770 # =====================================================================
771 # The profile: client or server.
772 # =====================================================================
773 # `client` is Alloy as docs/STACK.md describes it — compositor, greeter,
774 # session, browser, the whole desktop. `server` is the same machine with
775 # every one of those removed: the console, the shell and CLI stack, the
776 # hardware-health group, sshd, avahi and the themed bare console, and
777 # nothing that needs a screen.
778 #
779 # Ruled by Max 2026-08-01: one Containerfile with a build ARG, over the
780 # two-leaf-file shape and over having no server variant at all. Brief and
781 # the alternatives are in wiki `alloy-server-variant`; GO alloy 1372b159
782 # item 2.
783 #
784 # WHY THIS DOES NOT COST THE ASSERTIONS, which was the standing objection
785 # to doing it this way. This file's correctness mechanism is that it
786 # asserts, at build time, things a config file merely assumes: that the
787 # session wrapper parses, that the polkit grant names actions that exist,
788 # that satty and jq are really there for alloy-shot. Each of those checks
789 # was written because its absence had already caused a silent failure. The
790 # objection was that a build ARG turns them into `if` blocks, and a
791 # conditional assertion is one that can be skipped without a word.
792 #
793 # So no conditional here skips a check. **Every `$PROFILE` conditional in
794 # this file asserts on BOTH branches**: where `client` proves a thing is
795 # present and correct, `server` proves it is absent. There is no path
796 # through any of them that verifies nothing, which means a branch taken by
797 # mistake fails the build instead of quietly doing less. Two things hold
798 # that up:
799 #
800 # 1. The validity assertion immediately below. It is unconditional, and
801 # it runs before anything reads $PROFILE, so a typo can never fall
802 # through to a branch and build something nobody asked for.
803 # 2. tests/profile_split.rs, which reads this file and fails if any
804 # `$PROFILE` conditional lacks an `else`.
805 #
806 # That is a stronger guarantee than the file had before the split, because
807 # the `server` branches assert absence, which nothing used to check at all.
808 ARG PROFILE=client
809
810 # =====================================================================
811 # The other two builder choices: the browser, and language toolchains.
812 # =====================================================================
813 # Both come from `alloy image` (crates/alloy/src/image.rs), which is the
814 # builder TUI wiki `alloy-distribution` calls Alloy's whole distribution
815 # mechanism. It exposes only the choices Alloy deliberately declines to
816 # make, which is why these two are ARGs and the theme, terminal, editor
817 # and shell are not.
818 #
819 # BROWSER is not the non-endorsement it was until 2026-08-18. Alloy
820 # picks Firefox and defends it (wiki `alloy-byo-principle`), so this ARG
821 # is no longer "the choice Alloy declines to make" — it survives because
822 # `none` is a real answer, not an error: someone installing their own
823 # from Flathub should not pay for one they will remove.
824 #
825 # Alloy still makes no claim that Gecko is the better engine. The claim
826 # is about the candidate. Firefox is the only browser reachable on an
827 # image-based system whose defaults Alloy can stand behind, and standing
828 # behind them costs one file: /etc/firefox/pref/alloy.js.
829 ARG BROWSER=firefox
830
831 # LANGS is asked for by name in the rust comment further down: 610 MiB
832 # across 16 packages, "per-image language selection at mint time is the
833 # way this stops being one number for everyone". This is that. Comma
834 # separated, and validated against a curated set below rather than
835 # treated as a package list — the builder is not a package manager, and
836 # the gate lives here so it holds even when the TUI is bypassed.
837 #
838 # THE DEFAULT IS WHAT THE IMAGE ITSELF REQUIRES, ruled by Max 2026-08-17.
839 # EMPTY, ruled by Max 2026-08-27: nothing in the image requires a
840 # toolchain, so a stock image carries none. A toolchain that is merely
841 # useful is picked at mint time.
842 #
843 # The 2026-08-17 ruling is unchanged; what changed is that it was never
844 # applied to its last survivor. `rust` was kept on the claim that sandod
845 # shells out to `cargo build --release` and would break without cargo,
846 # which is an argument about the machine being a BUILD HOST rather than
847 # about the image requiring rust to function. Measured 2026-08-27: the
848 # `cargo install --git ...shop.git` at the top of this file runs in the
849 # `rust-build` builder stage, and the shipped image starts from
850 # `fedora-bootc` 600 lines later and compiles nothing at runtime. So the
851 # toolchain that builds shop was never the toolchain that ships.
852 #
853 # The go/python/zig sentence below is therefore the whole rule now,
854 # applied to every language including rust, rather than a note about
855 # three of them.
856 #
857 # GO WAS IN THIS DEFAULT AND CAME BACK OUT THE SAME DAY. Recording both
858 # rulings, because the second reverses the first and a bare `rust` reads
859 # like the question was never asked.
860 #
861 # The first rule was "the default is what compiles the shipped stack":
862 # someone rebuilding a program Alloy ships should not have to install a
863 # toolchain first. It was measured rather than assumed, by reading each
864 # shipped binary for a Go build id or a rustc path:
865 #
866 # Rust hx yazi nu starship zoxide btm dua rg fd bat eza satty, plus
867 # alloy and shop themselves. 14.
868 # Go direnv fzf gopass syncthing restic tailscale cliphist. 7.
869 # C sway mako swaylock, and the hardware-health group.
870 #
871 # What killed it is that the rule was never applied evenly. It already
872 # stopped short of the C tier — sway and mako want meson, ninja,
873 # wayland-protocols and a spread of -devel packages that no language
874 # toggle models — so "you can rebuild what we ship" was true for two
875 # thirds of the stack and quietly false for the rest. A rule with a hole
876 # that size is a preference. Then the exclusive-closure pass priced it:
877 # Go's marginal cost is 356 MB, six percent of the image, against seven
878 # programs nobody has rebuilt. Nothing in ~/Code is Go either — a full
879 # find turns up four tree-sitter fixtures in an archived repo and no
880 # go.mod — so the toolchain was not earning its place from the build-host
881 # side either.
882 #
883 # THE COST, STATED SO IT IS NOT DISCOVERED: rebuilding syncthing, restic,
884 # tailscale, gopass, direnv, fzf or cliphist from source on a stock image
885 # now needs `LANGS=rust,go` at mint time, or a toolchain in $HOME. That is
886 # the trade, and it is reversible at zero cost, which most things here are
887 # not.
888 #
889 # PARTLY REVISITED 2026-09-07. The hole described above is why the rule was
890 # dropped, and it is still there for the C tier. The Rust tier is not: the
891 # language block below now carries wayland-devel, libxkbcommon-devel and
892 # fontconfig-devel on the rust arm, so a LANGS=rust image can rebuild alloy
893 # and shop: the console, from this repo's workspace, and the terminal, from
894 # its own. Those are the two the runtime stage ships as packages of ours
895 # rather than as somebody's RPM, and the two a hotfix can reach.
896 #
897 # They are not everything this project compiles. `cargo build` over the
898 # workspace also produces alloy-drift, the backdrop, which ships as a plain
899 # binary in /usr/bin because nothing hotfixes it, and alloy-skelgen, which
900 # renders templates/ in the builder stage and never ships at all. Both come
901 # back for free on a rust image, since rebuilding the console builds the
902 # workspace that holds them.
903 #
904 # The reasoning is beside that install; the short version is that an uneven
905 # rule beats no rule when the even part is our own software.
906 #
907 # python and zig are off for the same reason, which is the rule: nothing
908 # in the image requires them.
909 #
910 # THE BUILD-HOST COST, and it is the one that bites this fleet. A build
911 # host is now a mint-time choice rather than a property of any profile.
912 # fw13's cutover turns on the machine still being able to compile, and an
913 # Alloy astra is a build host too; both need `LANGS=rust` passed when
914 # their images are minted, which wiki `alloy-fw13-migration` and
915 # `alloy-astra-migration` now say. Sando is the same fact from the other
916 # end: sandod refuses to compile anywhere but its configured build host,
917 # so that host's image is one that asked for rust.
918 #
919 # js is the newest arm and is off by the same rule. Nothing in the image
920 # is JavaScript. What asks for it is the build-host role: Sando's
921 # code_smoke gate runs `npm run build` over MNW's two frontends before it
922 # creates a database or boots anything, and MNW's own build.rs downgrades
923 # a missing npm to a cargo warning, so without node a fresh worktree
924 # produces no bundle at all rather than a stale one. The gate is what
925 # catches it, fatally and on purpose. So the arm exists and the default
926 # does not carry it: a machine that builds MNW asks for `LANGS=rust,js`,
927 # and every other mint pays nothing.
928 #
929 # Weight, MEASURED 2026-08-21 by minting the arm rather than by asking
930 # dnf about it: three packages, 22 MiB download, 87 MiB installed, and a
931 # 93.2 MiB delta between a LANGS=rust image and a LANGS=rust,js one
932 # (4,983,258,990 to 5,080,964,131 bytes, podman inspect .Size). nodejs
933 # is 162 KiB of that and nodejs-libs 78.2 MiB.
934 #
935 # The estimate this arm was filed with said five packages and 216 MiB,
936 # from `dnf install --assumeno`. That number included the weak deps, and
937 # install_weak_deps=False is what takes it to three packages -- so the
938 # estimate was measuring a transaction this arm does not run. Node costs
939 # about 40% of what the task predicted. nodejs-full-i18n, 31.6 MiB of the
940 # estimate, is confirmed absent from the built image.
941 ARG LANGS=
942
943 # DB is the database the image carries, and it exists because the version
944 # matters more than the presence. `postgres16` installs postgresql16 and
945 # postgresql16-server out of `updates`, whatever 16.x they are on the day:
946 # three packages, 9 MiB of download and 38 MiB installed, measured with
947 # `dnf install --assumeno` against 16.14-1.fc43 on 2026-08-21. The install arm
948 # greps for the major and not the patch, because the patch moves under us and
949 # already has: 16.15-1.fc43 by 2026-08-25.
950 #
951 # Fedora's own `postgresql-server` is 18.4 and is deliberately not what this
952 # installs. Sando's scratch cluster exists to test MNW against what production
953 # runs, and prod `alpha-west-1` is PostgreSQL 16; a scratch cluster two
954 # majors ahead tests a database nobody deploys.
955 #
956 # The default is `none` and the image carries neither binary today
957 # (`command -v psql postgres` finds nothing, `rpm -q postgresql` says not
958 # installed), so a default mint is unchanged by this dial existing.
959 #
960 # No `systemctl enable`, no initdb. The cluster is machine state rather than
961 # image state and belongs with the machine that runs it. This arm puts the
962 # binaries on PATH and stops.
963 #
964 # A dial rather than a gate on `PROFILE=server` (Max, 2026-08-25): whether the
965 # server profile is a build-host surface is a separate open question, and
966 # hanging the database off the profile would have answered it by accident. If
967 # it is later ruled that way, `DB` can default to `postgres16` under that
968 # profile without changing anything here.
969 ARG DB=none
970
971 # GUI is the system side of building a desktop app, and it is a dial for the
972 # same reason DB is: a machine that ships one and a machine that builds one need
973 # different things, and the difference should be a file rather than a memory.
974 #
975 # `tauri` installs the WebKitGTK development set the four Tauri apps compile
976 # against. Alloy ships no webkit at all today -- `rpm -qa | grep -i webkit` is
977 # empty on a stock mint -- so a build host without this dial fails at link time
978 # on the first app it is asked to build, having looked healthy until then.
979 #
980 # THE GLIBC QUESTION THIS USED TO BE TANGLED WITH IS SETTLED. Until 2026-09-04
981 # the plan was to build every AppImage inside a Debian-family distrobox, because
982 # Fedora 43's glibc is 2.42 against Ubuntu 24.04's 2.39 and cargo-tauri copies
983 # the WebKit libraries into the bundle while linuxdeploy excludes libc, so the
984 # copies meet the user's glibc rather than the builder's. Max ruled that a 2.42
985 # floor is acceptable and backwards compatibility is not a goal, which is what
986 # makes a native build the answer and this dial the whole of it.
987 #
988 # Not gated on PROFILE, on the same reasoning as the language block: a build
989 # host is a role rather than a profile, and astra is a server that builds.
990 ARG GUI=none
991
992 # TRIM is the one builder choice about the *base* rather than about Alloy.
993 # fedora-bootc is a general-purpose server base, and three of the things it
994 # carries are unreachable from any Alloy install however the machine is used:
995 # python3-botocore (an AWS SDK, with boto3 and s3transfer behind it),
996 # qemu-user-static for eighteen foreign architectures, and toolbox, whose job
997 # distrobox already does here. 22 packages, and nothing else in the image
998 # requires any of them.
999 #
1000 # WHAT IT IS WORTH, MEASURED RATHER THAN ASSUMED (2026-08-15, both profiles
1001 # built the same hour so the package versions match). dnf reports 297 MiB
1002 # freed, and `/usr` does shrink by 304 MiB: 170 MiB out of /usr/bin, 123 MiB
1003 # out of /usr/lib. **The image on disk shrinks by 3.8 MiB.**
1004 #
1005 # The difference is how the base is composed. Every file in the base's /usr is
1006 # a hardlink into /sysroot/ostree/repo/objects — `stat` reports nlink 2 and the
1007 # object is findable with `find -samefile` — so removing the /usr path drops a
1008 # link and frees no blocks, because the repo still holds the other one. dnf is
1009 # accounting for the rpm database, which is not the same question as what the
1010 # medium carries. Nothing here can prune the repo either: those objects belong
1011 # to the base commit, which is still referenced.
1012 #
1013 # So it is not the build-speed lever it looks like. What it should still buy is
1014 # the *installed* system, whose deployment is built from /usr rather than from
1015 # the image's own repo, and that has not been measured yet — believe it when an
1016 # install has been weighed both ways. Kept because the packages are genuinely
1017 # unreachable and because whoever measures that wants the switch to already
1018 # exist, not because the medium got smaller.
1019 #
1020 # WHAT THIS NEVER TOUCHES IS FIRMWARE, and that is a decision rather than an
1021 # oversight. `nvidia-gpu-firmware` alone is 101 MiB and is the largest single
1022 # thing a naive trim would take; astra needs it, and a medium built here has
1023 # to boot hardware nobody asked about at build time. The assertion below holds
1024 # firmware present on both branches so a future edit to this list cannot
1025 # quietly reach it.
1026 #
1027 # The accepted cost of `unused` is binfmt emulation: no `podman run` of a
1028 # foreign-architecture container. Every rule in ~/Code/CLAUDE.md points that
1029 # way already (builds are native per architecture, nothing is cross-compiled),
1030 # so this removes a capability the house style forbids using. `keep` is there
1031 # for whoever disagrees on their own machine.
1032 ARG TRIM=unused
1033
1034 # Unconditional, and first. Everything downstream trusts that these are
1035 # words from known sets, so this is the assertion the other assertions
1036 # stand on. `PROFILE=cleint` has to die here rather than silently build a
1037 # server image because no `then` branch matched, and `LANGS=rust,cobol`
1038 # has to die here rather than at a dnf error 400 lines later.
1039 RUN set -eu; \
1040 case "$PROFILE" in \
1041 client|server) ;; \
1042 *) echo "unknown PROFILE '$PROFILE'; expected 'client' or 'server'" >&2; exit 1 ;; \
1043 esac; \
1044 case "$BROWSER" in \
1045 firefox|none) ;; \
1046 helium) echo "BROWSER=helium was removed 2026-08-18: Alloy does not ship a third party's patchset over Chromium's defaults. Use 'firefox' or 'none'." >&2; exit 1 ;; \
1047 *) echo "unknown BROWSER '$BROWSER'; expected 'firefox' or 'none'" >&2; exit 1 ;; \
1048 esac; \
1049 for lang in $(echo "$LANGS" | tr ',' ' '); do \
1050 case "$lang" in \
1051 rust|c|go|python|zig|js) ;; \
1052 *) echo "unknown language '$lang'; the builder offers rust, c, go, python, zig and js" >&2; exit 1 ;; \
1053 esac; \
1054 done; \
1055 case "$DB" in \
1056 none|postgres16) ;; \
1057 *) echo "unknown DB '$DB'; expected 'none' or 'postgres16'" >&2; exit 1 ;; \
1058 esac; \
1059 case "$GUI" in \
1060 none|tauri) ;; \
1061 *) echo "unknown GUI '$GUI'; expected 'none' or 'tauri'" >&2; exit 1 ;; \
1062 esac; \
1063 case "$TRIM" in \
1064 unused|keep) ;; \
1065 *) echo "unknown TRIM '$TRIM'; expected 'unused' or 'keep'" >&2; exit 1 ;; \
1066 esac; \
1067 case "$PROFILE:$BROWSER" in \
1068 client:*|server:none) ;; \
1069 server:*) echo "PROFILE=server ships no graphical session and cannot carry BROWSER=$BROWSER" >&2; exit 1 ;; \
1070 esac; \
1071 echo "building profile=$PROFILE browser=$BROWSER langs=${LANGS:-none} trim=$TRIM db=$DB gui=$GUI"
1072
1073 # =====================================================================
1074 # Install-time filtering: translations and documentation
1075 # =====================================================================
1076 # 279 MB of a naive build is files rpm has marked with a language and 101 MB
1077 # is documentation. Two rpm settings drop most of it, and they only bind
1078 # packages installed *after* they are set, which is why they are here — above
1079 # terra-release, above every `dnf install` in this stage — rather than
1080 # somewhere more convenient. Set late they silently do almost nothing and the
1081 # build still passes, which is the failure mode the assertion after the last
1082 # install exists to catch.
1083 #
1084 # /etc/rpm/macros.image-language-conf %_install_langs en:en_US
1085 # /etc/dnf/dnf.conf tsflags=nodocs
1086 #
1087 # MEASURED, not argued (2026-08-17): gtk3, gtk4, helix and nushell built both
1088 # ways. `/usr/share/locale` grew 112 MB without these and 0.5 MB with them.
1089 # Alloy's own share of the cut is about 170 MB of translations plus about
1090 # 92 MB of documentation. The ~160 MB the base carries in before this line
1091 # runs is out of reach and stays. This is what actually makes Alpine images
1092 # small — it is not musl, and it works on glibc. Full lever list and the
1093 # figures behind it: wiki `alloy-image-size-levers`.
1094 #
1095 # Install-time filtering rather than a prune, and the difference is worth
1096 # stating: the files are never installed and the rpm database knows it, so
1097 # `rpm -V` still agrees with the image. Compare the cursor prune, where the
1098 # database declares 188 MB against 27 MB on disk by design. Prefer this shape
1099 # wherever both would work.
1100 #
1101 # THE MAN PAGE LOSS IS DECIDED, NOT OVERLOOKED. Fedora marks man pages %doc
1102 # and rpm offers no separate switch, so `nodocs` takes them with it. A default
1103 # Alloy install has no `man` for anything installed after this line; the
1104 # console carries its own help and docs/manual exists, so the loss is for
1105 # third-party tools rather than for Alloy's own surface. `TRIM=keep` restores
1106 # them. Ruled by Max 2026-08-17 over the recommendation, which was the
1107 # language macro alone.
1108 #
1109 # LICENCES ARE NOT AT RISK, checked both ways: rpm treats %license separately
1110 # from %doc, `/usr/share/licenses` stayed 12 MB and gtk4's licence stayed
1111 # readable. The assertion after the last install proves it anyway, because the
1112 # credits manifest is hand-curated against what the image ships and a future
1113 # rpm change here would be a licensing problem rather than a size one.
1114 #
1115 # It rides TRIM rather than taking an axis of its own. TRIM already means
1116 # "remove what this machine cannot use" and defaults to `unused`, so this is
1117 # the same argument one layer down and the builder matrix stays
1118 # profile x browser x langs x trim. Rejected: a LOCALES arg, which is the
1119 # fourth axis wiki `alloy-distribution` asks nobody to add, and doing it
1120 # unconditionally, which leaves no way back for a build-it-yourself consumer
1121 # who wants documentation.
1122 #
1123 # The baseline written here is what that assertion compares against: the
1124 # base's own `/usr/share/locale`, read at build time rather than hardcoded,
1125 # since it moves when the FROM line does. The assertion deletes it.
1126 RUN set -eu; \
1127 mkdir -p /usr/lib/alloy /etc/rpm; \
1128 du -sb /usr/share/locale | cut -f1 > /usr/lib/alloy/.locale-baseline; \
1129 [ -s /usr/lib/alloy/.locale-baseline ] \
1130 || { echo "could not read the base's /usr/share/locale size; the assertion below would have nothing to compare against" >&2; exit 1; }; \
1131 grep -q '^\[main\]' /etc/dnf/dnf.conf \
1132 || { echo "/etc/dnf/dnf.conf has no [main] section; tsflags would land outside every section and do nothing" >&2; exit 1; }; \
1133 if [ "$TRIM" = unused ]; then \
1134 echo '%_install_langs en:en_US' > /etc/rpm/macros.image-language-conf; \
1135 sed -i '/^\[main\]/a tsflags=nodocs' /etc/dnf/dnf.conf; \
1136 grep -q '^tsflags=nodocs$' /etc/dnf/dnf.conf \
1137 || { echo "tsflags=nodocs did not land in /etc/dnf/dnf.conf" >&2; exit 1; }; \
1138 echo "langs: en:en_US only, and no documentation or man pages (rides TRIM=unused)"; \
1139 else \
1140 rm -f /etc/rpm/macros.image-language-conf; \
1141 echo "langs: every language and all documentation (TRIM=keep)"; \
1142 fi
1143
1144 # =====================================================================
1145 # Third-party repos
1146 # =====================================================================
1147 # Tailscale is not in Fedora main; drop their .repo file directly.
1148 RUN curl -fsSL -o /etc/yum.repos.d/tailscale.repo \
1149 https://pkgs.tailscale.com/stable/fedora/tailscale.repo
1150
1151 # Terra — Fedora repo for parts of the Wayland ecosystem (swww,
1152 # starship, satty, and some others). Does not carry Rust binaries like
1153 # nushell or yazi; COPRs below handle those.
1154 # https://terra.fyralabs.com/
1155 #
1156 # `dnf clean all` matters more here than the install does. terra-release is
1157 # 674 BYTES; the layer measured 238 MB, and every byte of the difference was
1158 # repo metadata in /var/cache/libdnf5 (fedora 64 MB, updates 37 MB,
1159 # updates-archive 43 MB, terra 3.7 MB, and their solv caches). A later layer's
1160 # `dnf clean all` deletes it, which is why the built image shows 28 KB of
1161 # cache and the size stays: a delete in a later layer is a whiteout, not a
1162 # refund. Cleaning inside the layer that filled it is the only thing that
1163 # reclaims the bytes.
1164 #
1165 # It does not merely move the cost to the next layer. Measured 2026-08-17 on
1166 # fedora-bootc, this three-layer chain against itself:
1167 # as it was: 188 MB + 41.2 MB + 42.5 MB = 272 MB
1168 # as it is: 34.5 MB + 37 MB + 42.8 MB = 114 MB
1169 # The next dnf line re-downloads the metadata and deletes it in the same
1170 # layer, so it pays nothing to keep. What is left in each number is the
1171 # ~34 MB rpmdb rewrite, which is the floor every dnf layer pays and the
1172 # largest unattributed cost in the image. docs/IMAGE.md#size has it.
1173 #
1174 # One new line in the build log comes with this and is not a fault. The next
1175 # dnf run now meets a cold cache, so it prints
1176 # >>> repomd.xml GPG signature verification error: Signing key not found
1177 # for Terra and Tailscale before importing the key from the RPM-GPG-KEY-*
1178 # files terra-release itself installed, and then proceeds. It was invisible
1179 # before only because the cache the clean now removes was still warm.
1180 RUN dnf install -y --nogpgcheck \
1181 --repofrompath='terra,https://repos.fyralabs.com/terra$releasever' \
1182 terra-release \
1183 && dnf clean all
1184
1185 # dnf5's copr plugin isn't in the base bootc image; pull it so we
1186 # can `dnf copr enable` for the Rust-Wayland stragglers.
1187 RUN dnf install -y 'dnf5-command(copr)' && dnf clean all
1188
1189 # ublue-os/staging is the community-maintained COPR that packages
1190 # much of the Wayland / Rust ecosystem for atomic Fedora derivatives.
1191 # We're not using their base image, but their COPR is a legitimate
1192 # adoption (same relationship the rest of Alloy has to Fedora repos).
1193 #
1194 # Verified 2026-07-19: it does NOT carry wl-screenrec. That was the
1195 # reason it needed its own cargo stage and got deferred; a cargo stage
1196 # now exists for the console, so the toolchain half of that cost is
1197 # already paid and only clang plus the ffmpeg headers remain. See the
1198 # deferral note in docs/STACK.md#screen-recorder — the pick still
1199 # stands, the arithmetic behind refusing it has changed. Check here
1200 # before assuming a Rust Wayland tool is packaged.
1201 RUN dnf copr enable -y ublue-os/staging
1202
1203 # nushell binary (Terra has crates only)
1204 RUN dnf copr enable -y atim/nushell
1205
1206 # yazi binary
1207 RUN dnf copr enable -y varlad/yazi
1208
1209 # satty (screenshot annotator) needs no COPR. It resolves from Terra, at
1210 # 0.21.1-1.fc43 — checked 2026-07-30 by repoquery against the full repo
1211 # set this file configures, which is the only way to answer it, since the
1212 # question is which of four enabled repos wins.
1213 #
1214 # This block used to say satty "is expected in ublue-os/staging", and it
1215 # was not there: repoquery with terra absent returns nothing for satty at
1216 # all. The install line worked the whole time because Terra was answering
1217 # a question nobody had asked it. Recording the wrong repo costs nothing
1218 # until someone drops the one that was actually carrying the package.
1219
1220 # =====================================================================
1221 # fontconfig, for the faces staged much further down.
1222 #
1223 # The two house faces used to be two large downloads here, and this block
1224 # sat ahead of the package list because of it: they were the biggest
1225 # fetches in the build and the package list is the line that changes most
1226 # often. Neither half of that is true any more. The faces are cut in the
1227 # rust stage (wiki `typography-standard`), so there is nothing to
1228 # download, and copying them in *here* would put a layer that changes
1229 # whenever any Rust source does above the 7 GB of package installs below.
1230 # So the copy moved to the other `--from=rust-build` staging near the end
1231 # of the file, and what is left here is the one package `fc-cache` needs.
1232 #
1233 # CLIENT ONLY, since 2026-08-19, and the reason is where glyphs are drawn
1234 # rather than how much they weigh. Nothing on a headless box rasterises a
1235 # character: there is no compositor, no GTK, and no terminal, since shop is
1236 # client-only and the server carries only its uninstalled RPM. A TUI reached
1237 # over ssh is drawn by the CLIENT's font stack out of the client's own faces,
1238 # so the server end of that session never opens a font file. The image agreed
1239 # with itself on this already, since the profile prune takes
1240 # /etc/skel/.config/fontconfig, so `HOME=/etc/skel fc-match monospace` on a
1241 # server image answers Adwaita Mono, the base's face, and not Alloy's.
1242 #
1243 # Measured before removing anything (2026-08-19): the only binaries that link
1244 # libfontconfig are fontconfig's own fc-* tools, and no installed package
1245 # requires it. The base has none either, so declining to install it here is
1246 # what actually removes it rather than a line that a dependency quietly puts
1247 # back. It takes 784 KiB of tools, 120 KiB of cache and the 208 KiB of faces
1248 # below with it. What the measurement did not cover, when it was made, was a
1249 # server-profile image: none had been built, so what a server image ends up
1250 # carrying was read off the package set rather than seen on disk.
1251 #
1252 # The `else` branch is the claim from the other side, and it is a real one, with
1253 # one word since measured: it holds that the base still ships no fontconfig, so
1254 # this line is the only thing that could put one on a server image AT THIS POINT
1255 # IN THE BUILD. That last part is not a hedge. `GUI=tauri` installs the GTK and
1256 # WebKit devel set further down and drags fontconfig in underneath it, which is
1257 # what the first astra mint found on 2026-09-04. This assertion is unaffected --
1258 # it runs before that block and is still true where it stands -- and the claim
1259 # that matters more, about what the image ends up with, is asserted at the end of
1260 # the file, after every package install, where it can be true. That one now
1261 # splits on $GUI; see it for the reasoning.
1262 # =====================================================================
1263 RUN set -eu; \
1264 if [ "$PROFILE" = client ]; then \
1265 dnf install -y fontconfig; \
1266 dnf clean all; \
1267 command -v fc-cache >/dev/null \
1268 || { echo "fontconfig is installed and fc-cache is not on PATH; the face copy below would cache nothing and every app would rescan the font directories at startup" >&2; exit 1; }; \
1269 else \
1270 ! command -v fc-cache >/dev/null \
1271 || { echo "profile=server already carries fontconfig, before any line here asked for it; the base changed, so measure what wants it rather than leaving this branch asserting something untrue" >&2; exit 1; }; \
1272 echo "fontconfig: not installed; nothing headless rasterises a glyph"; \
1273 fi
1274
1275 # =====================================================================
1276 # Package additions — full Alloy stack per docs/STACK.md
1277 #
1278 # Two kinds of line live here and they are not the same kind of
1279 # decision. The curated picks are STACK.md's, each one a position the
1280 # docs defend. The "session prerequisites" group below is not curated
1281 # at all: fedora-bootc is a *server* base, so pieces a graphical
1282 # session simply cannot work without are absent and have to be named
1283 # explicitly. Treating that group as if it were a matter of taste is
1284 # how the image shipped with no audio stack at all — see the group's
1285 # own comment. Add to it when something is required; add to the
1286 # curated groups only with a STACK.md entry behind it.
1287 #
1288 # Sources noted per group. Nothing is downloaded directly any more: the
1289 # fonts were the last of it and the image cuts its own now (the
1290 # `quasi-type` stage above, docs/STACK.md#fonts). The
1291 # `flatpak` client is installed as the `sandboxed` level of the isolation
1292 # dial, which is also how users pull on-demand apps from Flathub
1293 # post-install; no Flatpaks are provisioned at build or first-boot
1294 # time.
1295 # =====================================================================
1296 # ---------------------------------------------------------------------
1297 # Base packages — installed on every profile.
1298 #
1299 # What is left when the graphical session goes away. Read the split as a
1300 # question about the machine rather than about taste: a package is here
1301 # if a headless box still has a use for it, and in the client block below
1302 # if it needs a compositor, a screen, or a person sitting at one.
1303 #
1304 # WEAK DEPENDENCIES OFF, the same as the client block below, and it took a
1305 # measurement to notice they were on here. The lean profile was accepting
1306 # recommends while the fat one refused them, which is backwards, and the
1307 # asymmetry was invisible because a recommend is by definition a package
1308 # nobody named. Resolved 2026-08-17 against fedora-bootc:43 with terra and
1309 # varlad/yazi: this list pulled 142 packages and 199 MiB with recommends
1310 # on, and 114 packages and 98 MiB with them off.
1311 #
1312 # What the flag sheds is not incidental. helix recommends a C toolchain
1313 # (gcc, cpp, binutils, make, libstdc++-devel, gcc-c++), and helix and
1314 # gopass between them recommend wl-clipboard, xsel, xdg-utils and libX11.
1315 # On `server` not one of those can run: there is no compositor and no X.
1316 # On `client` the clipboard tools are named explicitly further down, so
1317 # nothing there loses them.
1318 #
1319 # Measured on the built images rather than predicted, because the
1320 # prediction was half wrong: gcc-c++, libstdc++-devel, xsel, xdg-utils,
1321 # libX11 and man-pages are gone from `server`, and gcc, cpp, binutils and
1322 # make were STILL THERE, because `LANGS` defaulted to rust and rust
1323 # requires them. So the C compiler on a headless box was a language
1324 # decision and not an accident, and the LANGS default was what would have
1325 # to change to shed it. It changed on 2026-08-27: `LANGS` is empty by
1326 # default, so a stock image of either profile carries no compiler at all
1327 # and one minted with `LANGS=rust` gets the C toolchain back along with
1328 # it. The flag itself still only removes the C++ half and the X half.
1329 #
1330 # One recommend IS wanted and is now named below rather than inherited:
1331 # `helix-parsers`. It is 185 MiB of tree-sitter grammars and it is what
1332 # makes the editor highlight anything, so taking the flag without naming
1333 # it would have shipped an editor that renders every file as plain text
1334 # and said nothing about why. That is the trade the flag makes visible:
1335 # the answer is to name what is wanted, not to keep taking everything.
1336 #
1337 # NOT SUBTRACTABLE, stated so nobody re-measures it: `gopass` REQUIRES
1338 # `fish`, hard, 39.3 MiB, and it survives the flag. A second interactive
1339 # shell that nothing in the image runs, in both profiles, because Fedora's
1340 # spec requires the completions rather than recommending them. Either
1341 # gopass goes or the weight is accepted; it was chosen deliberately
1342 # (2026-07-30) so it stays until somebody decides otherwise.
1343 # ---------------------------------------------------------------------
1344 RUN dnf install -y --setopt=install_weak_deps=False \
1345 # Editor, shell, prompt. The terminal is not here: shop is built from
1346 # source in the rust-build stage and copied in below.
1347 #
1348 # helix-parsers is the tree-sitter grammar set, named because the
1349 # line above turns recommends off and it arrived as one. Without it
1350 # helix opens every file unhighlighted, which is the quietest way
1351 # this image could get worse.
1352 helix \
1353 helix-parsers \
1354 nushell \
1355 starship \
1356 zoxide \
1357 direnv \
1358 # File managers
1359 yazi \
1360 # System introspection
1361 bottom \
1362 dua-cli \
1363 # Command-line staples. The 2026-07-29 feature audit caught the
1364 # contradiction: this image ships zoxide and direnv on the argument
1365 # that a developer expects them, and shipped no `rg`. These are the
1366 # rest of that same argument. All four are Rust, which is not the
1367 # reason they are here but does mean they carry no interpreter.
1368 #
1369 # fd-find installs the binary as `fd` on Fedora; the package name is
1370 # the one that differs from the command, which is the only trap in
1371 # this group.
1372 ripgrep \
1373 fd-find \
1374 bat \
1375 eza \
1376 # Archives. p7zip alone handles 7z; p7zip-plugins is what adds rar,
1377 # which is the format a person actually receives and cannot open.
1378 # Splitting them would ship the half nobody hits.
1379 p7zip \
1380 p7zip-plugins \
1381 # WireGuard. NetworkManager 1.54 speaks WireGuard natively, so there
1382 # is no plugin to install and none exists to install: the packages a
1383 # search suggests (NetworkManager-wireguard, and the -gnome variant)
1384 # are not in Fedora 43. What is missing without this package is key
1385 # generation, since `nmcli` imports a config but will not mint one.
1386 # wireguard-tools carries `wg` and `wg-quick`.
1387 wireguard-tools \
1388 # The host firewall, ruled 2026-08-22 (docs/STACK.md `## Firewall`).
1389 # Before it the image had none at all: firewalld absent, no nftables
1390 # ruleset, every listener the preset enables reachable from whatever
1391 # network the machine was on.
1392 #
1393 # 2.0 MiB, and the dependency closure is 7.3 MiB because python3 and
1394 # nftables are already here. The Python daemon that gets cited as
1395 # firewalld's cost is a library load on this image, not an interpreter.
1396 #
1397 # In the base rather than on `server` alone, and the pick says why: a
1398 # laptop is on more untrusted networks than a server is. The two
1399 # profiles differ by their default zone, not by whether they have one,
1400 # and the zone assignment is further down beside the other config-tree
1401 # assertions.
1402 firewalld \
1403 # Device authorization for the USB bus, ruled 2026-08-22 (GoingsOn
1404 # alloy 63de3d4c: enforcement on by default, interactive activation,
1405 # and the gate drops whenever no usable keyboard is present).
1406 #
1407 # The view half already shipped and needs none of this: `alloy usb`
1408 # reads sysfs and works on an image that has never heard of usbguard.
1409 # What the package adds is the ability to act on what that screen
1410 # shows — deauthorize an attachment, and remember a decision.
1411 #
1412 # 1.3 MiB, and the closure is 4.8 MiB on this image: protobuf (3.3),
1413 # libqb (0.2) and usbguard-selinux (0.01). Measured on the built
1414 # client and server images both, and the number is worth stating
1415 # because on a bare fedora-bootc:43 the same install is 16.4 MiB —
1416 # usbguard-selinux pulls policycoreutils-python-utils and with it
1417 # python3-policycoreutils and python3-setools, all of which this
1418 # image already carries. Neither profile pays for them twice.
1419 #
1420 # In the base rather than on `client` alone. A server-profile machine
1421 # has a USB bus too, and the ruling gives that profile the stricter
1422 # half of the policy (no keyboard gate, because it has no keyboard by
1423 # design), so it is the profile that needs this more, not less.
1424 #
1425 # THE UNIT IS NOT ENABLED HERE, and 50-alloy.preset deliberately does
1426 # not list it. The reason is measured rather than cautious: the
1427 # package ships an EMPTY /etc/usbguard/rules.conf and
1428 # `ImplicitPolicyTarget=block`, and `PresentDevicePolicy=apply-policy`
1429 # applies that to devices already attached when the daemon starts. So
1430 # enabling the stock unit with the stock policy deauthorizes every USB
1431 # device on the machine at boot, keyboard included. The policy (step 3
1432 # of the task) and the keyboard gate (step 4) are what make the enable
1433 # line safe, and it lands with them, after the three bench tests the
1434 # task names. The assertion further down is what keeps that true.
1435 usbguard \
1436 # Hardware health. The base ships nvme-cli, so NVMe wear and SMART
1437 # were already readable, and nothing else was: no way to read ECC
1438 # corrected-error counts, no SMART for SATA, no path to a BMC. That
1439 # is ECC memory you cannot act on, and rising correctables are the
1440 # early warning ECC is bought for. It matters first for the bench
1441 # build host, whose characterization baseline requires ECC counters
1442 # provably readable before it is trusted as a reference (GoingsOn
1443 # tailoredmachines 18af3fb3), but a laptop with no ECC at all still
1444 # wants SMART on the disk it boots from, so these live in the base
1445 # rather than in either profile.
1446 #
1447 # This group is the one the old single-block comment already said
1448 # belonged "in the base rather than waiting on a server variant".
1449 # The variant now exists and the group did not move, which is the
1450 # outcome that comment was arguing for.
1451 #
1452 # rasdaemon carries ras-mc-ctl, which is the ECC readback. Note
1453 # edac-utils is NOT here: in Fedora 43 it is an empty stub package
1454 # (installsize 0) and rasdaemon replaced it.
1455 #
1456 # smartmontools is the largest of the four by dependency closure,
1457 # dragging perl for the mail path it will never use here. Taken
1458 # anyway: smartctl is the only SATA/USB SMART reader, and the four
1459 # together add 49 MiB installed.
1460 rasdaemon \
1461 smartmontools \
1462 # ipmitool talks to the AST2600 BMC on server boards from the host
1463 # side, which is how a headless box reports its own sensors and
1464 # event log without going through the web UI. The one package here
1465 # that is more useful on `server` than on `client`.
1466 ipmitool \
1467 # lm_sensors covers everything that is not behind a BMC: laptop
1468 # thermals, and on a desktop board the CPU and fan readings.
1469 lm_sensors \
1470 # The fuzzy picker behind both TUI menus in usr/bin: alloy-menu
1471 # ($mod+d, the launcher) and alloy-clipmenu ($mod+Shift+v, the read
1472 # half of the clipboard history). docs/STACK.md rejects graphical
1473 # launchers on thesis and names a terminal fuzzy picker in both
1474 # places; one package is the whole of that, and without it the
1475 # cliphist watchers the sway config has always run had nothing that
1476 # could read them back.
1477 #
1478 # Base rather than client despite that framing: fzf is a shell tool
1479 # first, and a headless box's interactive history search wants it
1480 # for reasons that have nothing to do with sway.
1481 fzf \
1482 # Continuity
1483 tailscale \
1484 syncthing \
1485 restic \
1486 # rsync is the transport under the fleet tools rather than a
1487 # convenience. ops-exec spawns the binary by name
1488 # (MNW/shared/ops-exec/src/transport.rs), and bentod, the bento
1489 # driver and sandod all link that crate, so a machine without it
1490 # cannot collect a build from another host or hand a release over.
1491 # That is the control plane and not a build step: it fails before
1492 # anything is compiled, and it fails identically on a box with no
1493 # graphical session, which is why it sits in the base.
1494 rsync \
1495 # Local discovery, both halves. Alloy could already ANNOUNCE a .local
1496 # name and could not RESOLVE one, which is a mismatch rather than a
1497 # missing feature, and it only shows up on the machine doing the
1498 # looking.
1499 #
1500 # avahi is listed here despite already being present. It arrived as a
1501 # weak dependency (avahi-libs comes in behind cups-libs, pipewire,
1502 # samba-client-libs, geoclue2) and enabled itself from its own package
1503 # preset, so the responder ran on every install without anyone choosing
1504 # it. Naming it makes that a decision, and keeps a future dependency
1505 # change from silently removing something the install flow now needs:
1506 # a headless box minted with a baked hostname is reached at
1507 # `<name>.local`, so publishing is load-bearing.
1508 #
1509 # Note this is the group whose weak-dependency carriers are mostly
1510 # client-side (cups-libs, pipewire, geoclue2). On a headless `server`
1511 # those are gone, so avahi arrives only because this line names it.
1512 # geoclue2 is back on a `server` that sets GUI=tauri, since it rides in
1513 # under GTK and WebKit, measured 2026-09-04 on astra; that changes which
1514 # carriers exist and not the conclusion, which is that naming avahi is
1515 # what makes it a decision — which is
1516 # exactly the profile where the install flow depends on it most.
1517 #
1518 # nss-mdns is the half that was missing. It needs no nsswitch edit from
1519 # us: /etc/nsswitch.conf is a symlink into authselect, and the package's
1520 # own %post inserts `mdns4_minimal [NOTFOUND=return]` ahead of `resolve`,
1521 # which is the ordering wanted. Doing it by hand would fight authselect
1522 # for ownership of a generated file.
1523 #
1524 # systemd-resolved is left alone. It ships `MulticastDNS=no` compiled in,
1525 # so it is not competing for UDP 5353; setting it to `yes` would break
1526 # avahi rather than add anything, since only one process can hold that
1527 # port.
1528 #
1529 # avahi-tools is the third half: `avahi-resolve` and `avahi-browse`, so a
1530 # machine can check its own mDNS rather than needing a second machine to
1531 # check it from. With Tailscale out of the minting path and installs
1532 # LAN-only, `ssh installer@<name>.local` IS the install flow, and its
1533 # failure mode is invisible on the box that is failing. Named explicitly
1534 # for the same reason as avahi above: nothing else pulls it in.
1535 avahi \
1536 avahi-tools \
1537 nss-mdns \
1538 # Artifact signing. Every Linux release this tree publishes is
1539 # signed with minisign: dist/sign-artifacts.sh checks for it on PATH
1540 # and exits if it is absent, and the recipe step that calls it is
1541 # sh_ok, so the run aborts rather than shipping the artifact
1542 # unsigned. Base rather than client for the same reason as rsync:
1543 # signing is a build-host act and has nothing to do with a desktop
1544 # session.
1545 minisign \
1546 # efibootmgr, for usr/bin/alloy-boot-entry, which names the firmware boot
1547 # entry after `bootc install` has made one. It arrives anyway as something
1548 # else's dependency; named here because the install step that needs it
1549 # exits 0 when it is missing, so losing it would show up as machines
1550 # quietly going back to saying Fedora rather than as a build failure.
1551 efibootmgr \
1552 # podman, the runtime behind two of `alloy pkg box`'s three isolation
1553 # levels: `workspace` calls it directly and distrobox wraps it for
1554 # `host`. flatpak is the third and is client-only, since `sandboxed`
1555 # exists to run desktop applications through portals.
1556 podman \
1557 # gopass is NOT here. It moved to the client block on 2026-08-17,
1558 # with gnome-keyring, which it always should have sat beside: a
1559 # personal password store is for the person at the machine, and a
1560 # headless build host has no use for one. It took `fish` with it,
1561 # which is 39.3 MiB gopass hard-requires and nothing else in the
1562 # image wants — see the note beside it there.
1563 #
1564 # git. Not in the base. aliases.nu ships six git aliases, helix's
1565 # diff gutters need it, docs/STACK.md sells EDITOR=hx on git commit
1566 # messages, and the stated audience is developers. The full package
1567 # rather than git-core: the extra weight is documentation and the
1568 # perl helpers, which is the difference between a git that works
1569 # and a git that works when you ask it a question.
1570 git \
1571 # The Rust toolchain used to be an unconditional line here, and its
1572 # comment argued at length about the 610 MiB it costs before ending
1573 # with "per-image language selection at mint time is the way this
1574 # stops being one number for everyone". That is now the `LANGS` ARG
1575 # and the toolchain block further down. The reasoning moved with it;
1576 # nothing about the cost or the build-host requirement changed.
1577 # xdg-user-dirs. Without it a new account gets a bare home and no
1578 # ~/Documents, ~/Downloads or ~/Pictures, which is what yazi opens
1579 # into. Base because yazi is base; the GTK file-chooser portal that
1580 # was the other consumer is client-only.
1581 xdg-user-dirs \
1582 # xdg-utils, for /usr/bin/xdg-mime and /usr/bin/xdg-open. Two roles,
1583 # and only one of them is a desktop's. The desktop one is yazi's
1584 # primary action, which is `xdg-open` on Enter and on Reveal.
1585 # The other is the build host's: tauri-bundler shells out to both
1586 # binaries at those hard-coded absolute paths while writing a Linux
1587 # bundle, so a profile without them cannot bundle a Tauri app at
1588 # all. That is the whole argument for base rather than the
1589 # client block where this used to sit: headless IS the build-host
1590 # profile, so the one profile that most needs to bundle the apps was
1591 # the one profile that could not. The mime database the desktop half
1592 # resolves against stays client-side; the binaries do not need it to
1593 # exist for the bundler's sake.
1594 xdg-utils \
1595 # jq. Already present, and that is exactly the problem: it comes from
1596 # fedora-bootc rather than from any line here, and the sway config's
1597 # Ctrl+Print binding (active-window screenshot) pipes swaymsg through it.
1598 # The config's own comment records the binding being enabled *because*
1599 # "jq is in the fedora-bootc base already" — a decision resting on an
1600 # observation of someone else's package set, which can change without
1601 # warning and would take the binding with it silently. Declaring it costs
1602 # nothing and turns an inherited assumption into a stated dependency.
1603 #
1604 # Base rather than client even though that binding is client-side:
1605 # usr/bin/alloy-shot is not the only consumer, and a JSON tool on a
1606 # build host needs no further argument.
1607 jq \
1608 && dnf clean all
1609
1610 # ---------------------------------------------------------------------
1611 # Client packages — the graphical session and everything that assumes one.
1612 #
1613 # Conditional, and the `else` branch is not decoration. Every `$PROFILE`
1614 # conditional in this file asserts something on both sides, so that a
1615 # branch taken by mistake fails the build instead of quietly skipping
1616 # work. Here that means `server` proves the compositor really is absent
1617 # rather than trusting that the `then` branch did not run. tests/
1618 # profile_split.rs enforces the rule across the whole file; the
1619 # PROFILE-validity assertion above is what makes the condition itself
1620 # trustworthy.
1621 #
1622 # WEAK DEPS ARE OFF HERE, since 2026-08-18, which closes the last gap in
1623 # the installation discipline (wiki `alloy-installation-discipline`): the
1624 # other three install sites in this file already passed the flag and this
1625 # one, behind the largest layer in the image, did not. The printing block
1626 # below says outright that it was split into its own RUN to get the flag
1627 # around a block that does not have it, which is a workaround for the gap
1628 # rather than a reason for it.
1629 #
1630 # TAKING THE FLAG IS NOT THE WHOLE MOVE, AND THIS IMAGE HAS THE CAUTIONARY
1631 # TALE TWICE OVER. A recommend is sometimes load-bearing: `helix-parsers`
1632 # is 185 MiB of grammars that arrived as one, and sway-systemd, which
1633 # starts the entire systemd user session, arrives as a recommend of
1634 # sway-config-upstream — as the sway config template has warned in writing
1635 # since July. So the rule is take the flag and then name back what is
1636 # wanted, and the group at the end of this list is that naming.
1637 #
1638 # MEASURED 2026-08-18 by resolving this exact package list both ways
1639 # against the same repos (fedora, updates, terra, the three COPRs):
1640 #
1641 # weak deps on 504 packages
1642 # weak deps off 418 packages
1643 # off, load-bearing named back 448 packages
1644 #
1645 # So the change drops 56 packages and 99.3 MiB installed and pulls in
1646 # nothing new, which is the half worth checking: a naive flag pulled six
1647 # packages that were not there before, and the JACK note below is why. The
1648 # two largest are `intel-mediasdk` (22.4 MiB) and
1649 # `intel-vpl-gpu-rt` (11.7 MiB), the QuickSync path, which the shipped
1650 # mpv.conf's `hwdec=auto-safe` does not select; `mesa-va-drivers` is what
1651 # it does use and is named back below. The rest is the GNOME metadata
1652 # estate (localsearch, tinysparql, poppler, exiv2, the osinfo set), iOS
1653 # device support, event sounds, seventeen supplementary Thai display faces
1654 # (6.9 MiB, and not coverage — see below), and bash and fish completions
1655 # for shells nobody here runs.
1656 #
1657 # THE ONE THAT LOOKED LIKE A CASUALTY AND IS NOT: the two
1658 # `default-fonts-*` metapackages. A metapackage whose payload is recommends
1659 # becomes a no-op under this flag, which would have taken script coverage
1660 # for most of the web with it and said nothing at all. Measured rather than
1661 # assumed: `default-fonts-other-sans` requires 62 per-language metapackages
1662 # and each of those requires its Noto face, so every script resolves with
1663 # the flag on. What drops is about twenty supplementary Thai display faces
1664 # recommended a level further down, which are alternative faces for a
1665 # covered script rather than coverage. The CJK monospace face is the one
1666 # real loss and is named back explicitly.
1667 # ---------------------------------------------------------------------
1668 RUN if [ "$PROFILE" = client ]; then \
1669 dnf install -y --setopt=install_weak_deps=False \
1670 # Compositor and Wayland session (Fedora main)
1671 sway \
1672 # Portals: -wlr is the wlroots backend sway needs for screencast.
1673 # -gnome was the wrong backend here (it drives screencast through
1674 # gnome-shell, which Alloy removes below). -gtk stays for the file
1675 # chooser.
1676 xdg-desktop-portal xdg-desktop-portal-gtk xdg-desktop-portal-wlr \
1677 # Notifications, screenshot annotate, wallpaper (bar = sway's built-in swaybar)
1678 mako \
1679 # notify-send, which is the *client* half. mako implements the
1680 # notification server and does not pull this in, so it has been arriving
1681 # as somebody else's transitive dependency. usr/bin/alloy-shot calls it on
1682 # every screenshot, and the failure mode if it goes missing is the exact
1683 # one that script was written to fix: a capture that happens and says
1684 # nothing. Same reasoning as jq above.
1685 libnotify \
1686 satty \
1687 swww \
1688 # Content viewers. These are what yazi opens files with, which is
1689 # test 2 of the packaging policy's in-image tests, so they follow
1690 # yazi's consumers rather than yazi itself: all three want a screen.
1691 mpv \
1692 imv \
1693 zathura zathura-pdf-mupdf \
1694 # Wayland session glue
1695 cliphist \
1696 # Wi-Fi, and it is two packages because NetworkManager on Fedora does
1697 # not do Wi-Fi by itself. The device plugin lives in
1698 # NetworkManager-wifi and the supplicant is a separate package again;
1699 # the base is fedora-bootc, a server base, and carries neither. Without
1700 # them the kernel loads the driver and creates the interface, NM never
1701 # presents it, and `nmcli device` lists loopback alone. Measured on
1702 # fw12 2026-09-03: a laptop with working mt7xxx firmware and no way to
1703 # reach a network, on a distro whose own description is laptop-first.
1704 #
1705 # Client-only, because the server profile's machines are wired by role
1706 # (astra is the only one in evidence). If a headless box on Wi-Fi ever
1707 # becomes a role, these move to the base block rather than being
1708 # duplicated into the server branch.
1709 NetworkManager-wifi \
1710 wpa_supplicant \
1711 # And the Intel wireless firmware, which is a separate defect with the
1712 # same symptom and a different layer. `--setopt=install_weak_deps=False`
1713 # above is what drops it: Fedora's linux-firmware *requires* the
1714 # atheros, brcmfmac, mt7xxx and nxpwireless splits and only
1715 # *recommends* the iwlwifi ones, so the flag silently produced an image
1716 # that can drive every wireless chip except Intel's. Measured on fw12
1717 # 2026-09-03, where the kernel said it in as many words:
1718 #
1719 # Detected Intel(R) Wi-Fi 6E AX211 160MHz
1720 # Direct firmware load for iwlwifi-so-a0-gf-a0-89.ucode failed
1721 # no suitable firmware found!
1722 #
1723 # The driver loads either way, so `lsmod` shows iwlwifi present with a
1724 # refcount of zero and no interface is ever created. Same silent shape
1725 # as the missing plugin above: nothing fails, and the hardware is
1726 # simply not there.
1727 #
1728 # -mvm covers the modern parts (7260 onward, which is every Intel card
1729 # in a machine Alloy claims to support, fw12's AX211 included). -mld is
1730 # the newest series and -dvm is pre-2013; neither is in the tested
1731 # matrix, and this line grows when the matrix does rather than in
1732 # anticipation.
1733 iwlwifi-mvm-firmware \
1734 # Lock + idle. swaylock is the adopted lock surface per
1735 # docs/STACK.md#lock. A session-lock surface is graphical and
1736 # cannot be a TUI, so Alloy adopts rather than authors it. The
1737 # themed config in etc/skel had no package behind it until now.
1738 swaylock \
1739 swayidle \
1740 swayosd \
1741 # Fingerprint unlock, which swaylock reaches without knowing it: its
1742 # /etc/pam.d/swaylock is `auth include login`, login includes
1743 # system-auth, and system-auth is what authselect rewrites when the
1744 # with-fingerprint feature is enabled below. So the lock screen, the
1745 # greeter and run0 all gain the same unlock from one switch, and none
1746 # of them needs an Alloy-authored PAM file.
1747 #
1748 # fprintd-pam is the half that matters and is not pulled in by fprintd:
1749 # the daemon can enroll a finger all day, but without the PAM module
1750 # nothing ever asks it. Both named so neither arrives as somebody
1751 # else's transitive dependency.
1752 #
1753 # Client-only because the reader is a laptop part and because the
1754 # authselect feature this feeds is applied in a client-only layer.
1755 # A headless box authenticates with a key, not a finger.
1756 fprintd \
1757 fprintd-pam \
1758 playerctl \
1759 gammastep \
1760 # Greeter
1761 greetd \
1762 tuigreet \
1763 # No initial-setup. It was here because the bootc Anaconda flow has no
1764 # user-creation spoke, so an install otherwise finished with root
1765 # locked and no way in. `alloy install` creates the account now, and
1766 # keeping it made things worse rather than redundant: it ships enabled
1767 # in both graphical.target.wants and multi-user.target.wants, and on
1768 # first boot it takes the console and blocks on an Anaconda text spoke
1769 # reading "[!] User creation (No user will be created)", in front of a
1770 # perfectly good uid 1000 account. Verified in QEMU 2026-07-20.
1771 # Removing it is what lets first boot reach greetd.
1772 # Cursor, GTK theme
1773 bibata-cursor-theme \
1774 adw-gtk3-theme \
1775 # dconf, named rather than left to a transitive pull. It is what
1776 # compiles and reads /etc/dconf/db/local, which is where Alloy's
1777 # GTK schema defaults live (the file chooser's hidden files, so
1778 # far). GTK reads that database through gsettings whether or not
1779 # the CLI is present, but `dconf update` at build time and the
1780 # assertion after it both need the binary, and a defaults
1781 # mechanism that depends on someone else's dependency graph is
1782 # one release from silently not applying.
1783 dconf \
1784 # Screenshot capture + region-select (sway has no built-in grab)
1785 grim slurp \
1786 # The browser is not a line here any more: it is the `BROWSER` ARG
1787 # and the block further down, because it is the one stack pick
1788 # Alloy declines to make. docs/STACK.md:150 already called it a
1789 # non-endorsement rather than a pick; this is where that becomes
1790 # true of the build rather than only of the prose.
1791 # Script coverage for the browser. Until now the image carried
1792 # google-noto-sans-vf (Latin, Greek, Cyrillic) and nothing else, so
1793 # the browser rendered every CJK, Arabic, Hebrew, Indic and Thai page
1794 # as rows of missing glyphs. Shipping a browser as the default and
1795 # then not carrying the fonts a large share of the web is written in
1796 # is the same defect class as the emoji alias that named a font the
1797 # image never installed: the config was fine, the coverage was
1798 # absent, and nothing said so. The fonts outlive any one browser
1799 # pick, so this line does not move when the pick does.
1800 #
1801 # These are Fedora's own coverage metapackages rather than a hand-
1802 # picked list, because the failure mode of hand-picking is a script
1803 # nobody on this end reads being the one left out. Cost stated rather
1804 # than absorbed: -cjk-sans is 62 MiB over 4 packages (the CJK faces are
1805 # simply large), -other-sans is 13 MiB over 95.
1806 #
1807 # THESE SURVIVE THE WEAK-DEPS FLAG, and it was worth checking rather
1808 # than assuming, because a metapackage whose payload is recommends
1809 # would have become a no-op that installs a manifest and nothing to
1810 # render with. Measured 2026-08-18: -other-sans *requires* 62
1811 # per-language metapackages, each of which *requires* its Noto face,
1812 # so every script this line exists for is a hard dependency and all
1813 # 62 resolve with the flag on. What the flag does drop is about
1814 # twenty supplementary Thai display faces recommended one level
1815 # further down: Sarabun, Charmonman, Chakra Petch and their
1816 # neighbours. Those are alternative faces for a script that is
1817 # already covered rather than coverage, so they go. The one real
1818 # loss is the CJK monospace face, named back explicitly below.
1819 #
1820 # No fontconfig change goes with this. The aliases in
1821 # etc/skel/.config/fontconfig/fonts.conf use <prefer>, which leaves
1822 # fontconfig free to fall through to a font that has the glyph, so
1823 # Quasi Body stays the sans for Latin text and Noto covers what it
1824 # cannot.
1825 #
1826 # Emoji is untouched and still deliberately absent (docs/STACK.md):
1827 # neither metapackage carries an emoji font, which is why declining
1828 # emoji survives this line.
1829 default-fonts-cjk-sans \
1830 default-fonts-other-sans \
1831 # flatpak, the `sandboxed` rung of the isolation dial and the install
1832 # path for everything Alloy does not bake in. Client-only: portals,
1833 # a session bus and a screen are what that rung is for, and on a
1834 # headless box `alloy pkg box` simply reports the backend absent,
1835 # which the boxes tab already handles as a state rather than an error.
1836 flatpak \
1837 # Secrets (docs/STACK.md#secrets), the Secret Service half.
1838 #
1839 # gnome-keyring is the Secret Service provider, and it is here
1840 # because its absence broke Make Creative's own software on Make
1841 # Creative's own distro. Nothing in the image registered
1842 # org.freedesktop.secrets, and the Rust `keyring` crate and Tauri's
1843 # credential plumbing both resolve to it on Linux, so GoingsOn and
1844 # Balanced Breakfast had nowhere to put a credential and
1845 # synckit-client had nowhere to persist its E2EE key. That surfaces
1846 # as a confusing runtime error inside an app, four layers from the
1847 # cause.
1848 #
1849 # gnome-keyring-pam is a SEPARATE package and not a weak dependency
1850 # of the one above, which matters more than it looks. Fedora's
1851 # /etc/pam.d/greetd already carries the two stanzas that unlock the
1852 # keyring with the login password, and both are `-` prefixed:
1853 #
1854 # -auth optional pam_gnome_keyring.so
1855 # -session optional pam_gnome_keyring.so auto_start
1856 #
1857 # A leading `-` tells PAM to skip a module it cannot load, without a
1858 # word in any log. So the provider alone gets you a Secret Service
1859 # that works and a keyring locked behind a second password prompt
1860 # nobody chose, and the config that was supposed to prevent that is
1861 # sitting right there looking correct. Same defect class as the
1862 # emoji alias and the swayosd unit path: configuration that is
1863 # correct about something absent. Both packages or neither.
1864 #
1865 # Nothing here ships a /etc/pam.d file. greetd owns that one and the
1866 # stanzas are already in it, so the fix is a package rather than a
1867 # config edit; the assertion further down is what keeps that true.
1868 # That pairing is also why this group is client-only: the PAM half
1869 # hangs off greetd's own file, and there is no greetd on `server`.
1870 #
1871 # KeePassXC also provides the interface and was rejected for
1872 # dragging Qt into a ratatui/egui design language, not on
1873 # capability. systemd-creds is unrelated and stays: service
1874 # credentials for daemons, no interactive component.
1875 gnome-keyring \
1876 gnome-keyring-pam \
1877 # gopass is the other half of that sentence: gnome-keyring is the
1878 # API programs call, gopass is where a person keeps their own
1879 # logins (docs/STACK.md#secrets). age backend, synced by git,
1880 # decided 2026-07-30 and re-taken 2026-08-17 on the question of
1881 # what to build a house GUI over: a directory of age-encrypted
1882 # files is git-native, so a concurrent edit is a conflict on one
1883 # secret with a diff, and it reuses the age identity the sops
1884 # migration already requires. A single-blob vault (KDBX) syncs as
1885 # two silent copies, which is the failure this stack keeps
1886 # rejecting.
1887 #
1888 # HERE RATHER THAN IN THE BASE, moved 2026-08-17. It is a personal
1889 # store for the person at the machine, and it costs `fish`: Fedora's
1890 # gopass hard-requires /usr/bin/fish for one completion file, 39.3
1891 # MiB of a shell nothing in this image runs, and no flag touches a
1892 # hard dependency. Two Terra completion packages then follow fish in
1893 # by conditional dependency. Client pays it because gopass is worth
1894 # it there; server no longer pays it at all.
1895 #
1896 # The comment this replaced said gopass "needs no gpg on the age
1897 # backend". True of the backend and false of the package: it
1898 # requires gnupg2 regardless, and gnupg2 is installed.
1899 gopass \
1900 # -----------------------------------------------------------------
1901 # Session prerequisites — not curated picks. See the header above.
1902 # fedora-bootc is a server base; everything in this group is
1903 # something the shipped configuration already assumes exists.
1904 # -----------------------------------------------------------------
1905 # Audio. The base has no sound stack whatsoever: no pipewire, no
1906 # wireplumber, and so no `pactl`, which crates/alloy/src/audio.rs
1907 # shells out to for every reading and every write. `alloy audio`
1908 # is documented as shipped (docs/CONSOLE.md) but had nothing to
1909 # front. Established by probing quay.io/fedora/fedora-bootc:43
1910 # directly (rpm -q, and `pactl` absent from PATH), not observed on
1911 # hardware — the QEMU punch list is where that gets confirmed. The
1912 # Fn-key volume binds, mpv, and anything playerctl controls have
1913 # the same hole under them.
1914 #
1915 # pulseaudio-utils is what actually carries /usr/bin/pactl (the
1916 # pipewire-pulseaudio package is the daemon-side compat shim, not
1917 # the CLI). Both are needed. No preset lines accompany these:
1918 # Fedora's own 90-default-user.preset already socket-activates
1919 # pipewire and pipewire-pulse, and `systemctl preset-all` below
1920 # picks that up.
1921 pipewire \
1922 wireplumber \
1923 pipewire-pulseaudio \
1924 pulseaudio-utils \
1925 # wl-clipboard. The sway config exec's `wl-paste --watch cliphist
1926 # store` twice at session start (docs/STACK.md#clipboard-history),
1927 # and wl-copy is how anything gets back out of the history. cliphist
1928 # was installed without it, so both watchers failed at every login
1929 # and the clipboard history could never be populated or pasted from.
1930 wl-clipboard \
1931 # --------------------------------------------------------------
1932 # Named back in, because weak deps are off above and each of these
1933 # was arriving as somebody's recommend. Measured 2026-08-18 by
1934 # diffing the resolved transaction both ways; anything not here is
1935 # in the 40 packages the flag drops, and that list is in the
1936 # comment above this RUN.
1937 # --------------------------------------------------------------
1938 #
1939 # sway-systemd is the one that would have taken the whole session.
1940 # It arrives as a recommend of sway-config-upstream and its
1941 # /etc/sway/config.d/10-systemd-session.conf is what execs
1942 # session.sh, which starts sway-session.target, which
1943 # graphical-session.target binds to. Every user unit in the image
1944 # depends on that chain: pipewire, wireplumber, xdg-user-dirs, the
1945 # clipboard watchers. templates/etc/skel/.config/sway/config.in has
1946 # said so in writing since 2026-07-21, when it was proven by
1947 # reading the uid 1000 journal off a booted install.
1948 sway-systemd \
1949 # The database xdg-open resolves against. xdg-utils itself moved
1950 # to the base block on 2026-08-21 (see the note beside it there);
1951 # these two are the half that only a desktop reads.
1952 # etc/skel/.config/yazi/yazi.toml binds `xdg-open` to Enter and to
1953 # Reveal, and without a mime database it opens the wrong handler
1954 # rather than none. mailcap is the /etc/mime.types half of the
1955 # same answer.
1956 desktop-file-utils mailcap \
1957 # Vulkan, which docs/IMAGE.md#size argues about at length and
1958 # treats as a stack decision rather than an accident: six of its
1959 # twelve ICDs drive hardware this image boots on. It has no rpm
1960 # dependents at all, because the loader dlopens an ICD off a JSON
1961 # manifest, so it was arriving as a recommend and nothing would
1962 # have reported its absence except a black window.
1963 mesa-vulkan-drivers \
1964 # VA-API, which is what `hwdec=auto-safe` in the shipped mpv.conf
1965 # actually selects. Hardware decode is a documented behaviour of
1966 # the video player (docs/STACK.md#video), so it is not a recommend
1967 # here.
1968 mesa-va-drivers \
1969 # mpv plays streams through yt-dlp, capped at 1080p in the shipped
1970 # config, and both STACK.md and manual chapter 9 promise it. Its
1971 # own recommends (the metadata and cipher helpers) are not named
1972 # back: they degrade features rather than remove playback.
1973 yt-dlp \
1974 # upower. crates/alloy/src/status.rs reads battery from sysfs
1975 # rather than through it and says so, but docs/STACK.md states it
1976 # is in the image, the power stack that HARDWARE-FW12 documents
1977 # wants it, and it is 311 KiB.
1978 upower \
1979 # PipeWire's ALSA and JACK shims. These are the ones that punish a
1980 # naive flag rather than merely thinning the image: without the
1981 # JACK shim the resolver satisfies the same interface with the real
1982 # JACK daemon instead, and the measurement caught it doing exactly
1983 # that (jack-audio-connection-kit, libffado, glibmm2.4 and three
1984 # more appeared in the transaction the flag was supposed to
1985 # shrink). ALSA-only applications route through the shim too.
1986 pipewire-alsa pipewire-jack-audio-connection-kit \
1987 # The camera path. Alloy ships no video-call application and the
1988 # browser is one, so a webcam that does not work in a call is a
1989 # defect on a laptop-first distro. -ipa is where the per-pipeline
1990 # image processing lives and libcamera is close to useless without
1991 # it.
1992 libcamera libcamera-ipa pipewire-plugin-libcamera \
1993 # CJK coverage for the cell grid. The fonts RUN below carries the
1994 # proportional metapackages; this is the monospace face, which a
1995 # terminal showing CJK text needs and a sans metapackage does not
1996 # supply.
1997 google-noto-sans-mono-cjk-vf-fonts \
1998 && dnf clean all \
1999 # bibata-cursor-theme installs FOURTEEN directories and Alloy names one.
2000 # At 179 MB it was the third-largest package in the image, behind the
2001 # browser and helix's parsers, and ahead of rustc on a mint that asked
2002 # for rust (nothing does by default since 2026-08-27). Fedora ships no
2003 # per-variant subpackage, so a prune is the only lever.
2004 #
2005 # Measured in the built image: Bibata-Modern-{Amber,Classic,Ice} at 27 MB
2006 # each, Bibata-Original-{Amber,Classic,Ice} at 12 MB, and a -Right variant
2007 # of all six at 11-12 MB. Keeping Bibata-Modern-Classic drops 159 MB.
2008 #
2009 # It runs INSIDE this RUN on purpose. Deleting them in a later layer
2010 # writes whiteouts and reclaims nothing from the image, the same trap the
2011 # terra metadata was in.
2012 #
2013 # KEEP is spelled here rather than read from /etc/skel, which is copied in
2014 # further down and does not exist yet. The rename this cannot see is caught
2015 # at the other end instead: the assertion after the skel copy reads the
2016 # theme name back out of skel and fails if its directory is not here.
2017 && KEEP=Bibata-Modern-Classic \
2018 && test -d "/usr/share/icons/$KEEP" \
2019 && for theme in /usr/share/icons/Bibata-*; do \
2020 [ "$theme" = "/usr/share/icons/$KEEP" ] || rm -rf "$theme"; \
2021 done \
2022 && test -d "/usr/share/icons/$KEEP" \
2023 # And the Wi-Fi plugin is really there. `dnf install` succeeding is not
2024 # the check: what a wireless machine needs is the plugin file NM loads at
2025 # runtime, and the failure this guards is silent by construction. Nothing
2026 # errors, no unit fails, `alloy net` renders correctly, and the machine
2027 # simply has no wireless device. It went unnoticed from the first image
2028 # until 2026-09-03 because every Alloy install so far was reached over a
2029 # wire.
2030 && ls /usr/lib64/NetworkManager/*/libnm-device-plugin-wifi.so >/dev/null 2>&1 \
2031 && rpm -q wpa_supplicant >/dev/null \
2032 # The firmware half, asserted by file rather than by package, because
2033 # what the driver needs is a blob at a path and the package is only how
2034 # it gets there.
2035 && ls /usr/lib/firmware/iwlwifi-*.ucode* >/dev/null 2>&1; \
2036 else \
2037 # The asserting else. Five sentinels, one per reason a package could
2038 # have landed here anyway: sway is a direct name in the `then` branch,
2039 # pipewire is the group most likely to arrive as somebody's weak
2040 # dependency, and greetd is what would silently turn a headless box
2041 # into one waiting at a login prompt on a VT nobody can see.
2042 #
2043 # gopass and fish joined them 2026-08-17, when gopass moved out of the
2044 # base block. fish is the interesting one: nothing in the image runs
2045 # it, it is here only as gopass's hard dependency, and it is 39.3 MiB.
2046 # If either reappears on a server image, the move has been undone by
2047 # something and the 64 MiB is back without anyone deciding it.
2048 for unwanted in sway pipewire greetd gopass fish; do \
2049 rpm -q "$unwanted" >/dev/null 2>&1 \
2050 && { echo "profile=server but $unwanted is installed; the client branch ran or something pulled it in" >&2; exit 1; }; \
2051 done; \
2052 echo "profile=server: no compositor, no audio stack, no greeter"; \
2053 fi
2054
2055 # =====================================================================
2056 # The browser, from the BROWSER choice.
2057 # =====================================================================
2058 # Its own layer rather than a line in the block above, because it is the
2059 # one thing here a user chooses and a rebuild that changes only the
2060 # browser should not re-resolve the whole stack.
2061 #
2062 # Firefox, picked and defended as of 2026-08-18. Wiki
2063 # `alloy-byo-principle`, "The browser stops being BYO".
2064 #
2065 # It is from Fedora's own repos, which is not incidental: they stay
2066 # enabled after install, so this is a browser a user can also reach on a
2067 # machine that chose `none`. That property is now a selection criterion
2068 # for anything Alloy recommends, and it is what ended the previous
2069 # ruling.
2070 #
2071 # WHAT WAS HERE UNTIL TODAY, and why it is gone. Helium is
2072 # ungoogled-chromium with the defaults Alloy used to hand-build in fifty
2073 # Firefox prefs, packaged by Terra as `helium-browser-bin`. It was the
2074 # default precisely because it needed no configuration. Two things ended
2075 # it. Terra is disabled post-install (see the repo cleanup layer), so
2076 # Helium was unreachable on any machine that did not bake it in. And it
2077 # is a maintained patchset over another vendor's bad defaults, which is
2078 # not a thing Alloy ships: Helium is doing its best to make a standard
2079 # approach tolerable, and Alloy is doing its best to improve standard
2080 # approaches. Anyone who wants it can enable Terra on their own machine
2081 # and layer it, which is their call and not a shape this image carries.
2082 #
2083 # The cost of picking Firefox is that stock Firefox has bad defaults by
2084 # Alloy's own test, so the pick is paid for in /etc/firefox/pref/alloy.js
2085 # — anti-features removed, one hidden control restored, nothing about
2086 # appearance. It arrives with `COPY etc/ /etc/` and is asserted below.
2087 #
2088 # The assertion runs on every path including `none`, and that is the
2089 # point: it proves exactly one browser is installed, or that none is, so
2090 # a `BROWSER=none` image that picked one up as a dependency fails here
2091 # instead of shipping a browser nobody chose.
2092 RUN set -eu; \
2093 if [ "$PROFILE" = client ]; then \
2094 case "$BROWSER" in \
2095 firefox) dnf install -y firefox && dnf clean all ;; \
2096 none) echo "no browser in this image, by choice" ;; \
2097 esac; \
2098 else \
2099 echo "no browser: this profile has no session to run one in"; \
2100 fi; \
2101 installed=""; \
2102 for candidate in firefox helium-browser-bin; do \
2103 rpm -q "$candidate" >/dev/null 2>&1 && installed="$installed $candidate"; \
2104 done; \
2105 installed="$(echo $installed)"; \
2106 expected=""; \
2107 if [ "$PROFILE" = client ] && [ "$BROWSER" != none ]; then \
2108 expected="firefox"; \
2109 fi; \
2110 [ "$installed" = "$expected" ] \
2111 || { echo "browser mismatch: asked for '${expected:-none}', image has '${installed:-none}'" >&2; exit 1; }; \
2112 echo "browser: ${expected:-none}"
2113
2114 # =====================================================================
2115 # Language toolchains, from the LANGS choice.
2116 # =====================================================================
2117 # The Containerfile asked for this before it existed. The rust line used
2118 # to carry a comment ending "per-image language selection at mint time is
2119 # the way this stops being one number for everyone", and this is that,
2120 # arrived at from the builder rather than from mint time.
2121 #
2122 # Rust is not in the default set any more (2026-08-27): nothing in the
2123 # image requires it, and the Sando argument that kept it is about a
2124 # machine being a build host rather than about the image working. It
2125 # costs 610 MiB across 16 packages, most of it rust-std-static (164 MiB)
2126 # and llvm-libs (139 MiB), which is an order of magnitude above the
2127 # hardware-health group and by a distance the largest optional thing in
2128 # the image — so it is exactly the kind of thing a mint should be asked
2129 # about rather than handed.
2130 #
2131 # No separate linker line: `rust` pulls gcc, binutils and glibc-devel, so
2132 # cc arrives with it. rustup is deliberately not offered here — it
2133 # installs into $HOME, needs nothing from the image, and stays the answer
2134 # for a pinned or nightly toolchain.
2135 #
2136 # Profile-blind: languages are as useful on a build host as on a laptop,
2137 # which is most of why the build-host role works at all. The empty
2138 # default is profile-blind for the same reason — a client image has no
2139 # more claim on a compiler than a server one.
2140 #
2141 # WHAT `c` IS, because it is not a language row like the others. It is the
2142 # toolchain somebody needs to clone a repository and build it: gcc and g++,
2143 # make, cmake, meson and ninja, and the autotools trio. Ruled 2026-09-07
2144 # (Max), who put the goal as caring "more about being able to clone a repo
2145 # and compile it from scratch than keeping the original compiler for the
2146 # shipped binary".
2147 #
2148 # That framing is what keeps this bounded. Reproducing a shipped binary means
2149 # matching its toolchain and carrying every library header it linked, which is
2150 # unbounded and is the "spread of -devel packages that no language toggle
2151 # models" that killed the rebuild-what-we-ship rule. Building an arbitrary
2152 # checkout means having the compilers and the build systems, and letting the
2153 # person layer whatever that project names. The second is a fixed cost and
2154 # this row is it: 193 MiB across 86 packages over a rust image, measured with
2155 # `dnf install --assumeno` on fedora-bootc:43, 2026-09-07.
2156 #
2157 # gcc is named here as well as arriving with rust, so `LANGS=c` alone is a
2158 # working C toolchain rather than one that depends on another row being on.
2159 #
2160 # Weak dependencies off, matching every other install in this file. cmake
2161 # recommends its full documentation set and nothing here reads it.
2162 #
2163 # THREE -devel PACKAGES RIDE WITH rust, and they are not the language's.
2164 # wayland-devel, libxkbcommon-devel and fontconfig-devel are what shop
2165 # links against, and shop is the terminal this image ships and runs. The
2166 # runtime libraries were always here; only the headers were missing, so a
2167 # machine with a full Rust toolchain still could not rebuild its own
2168 # terminal. Measured 2026-09-07 on a LANGS=rust image: cargo, rustc and
2169 # cc all present, and pkg-config found none of the three.
2170 #
2171 # Attached to the rust arm rather than installed unconditionally, because
2172 # headers with no compiler are weight nobody can use. 79 MiB installed
2173 # across 61 packages, beside the 610 MiB of the toolchain they arrive with.
2174 #
2175 # THIS RESTORES A RULE THIS FILE ONCE DROPPED, for the tier it covers.
2176 # The rule was "you can rebuild what we ship", and it was dropped where
2177 # the Go tier was priced, because it had never been applied evenly: the C
2178 # tier (sway, mako, swaylock) wants meson, ninja, wayland-protocols and a
2179 # -devel spread no language toggle models, so the rule was two thirds true
2180 # and quietly false for the rest. That objection stands and is not
2181 # answered here. What changed is the judgement about it: an uneven rule is
2182 # worth more than no rule when the even part is the software Alloy itself
2183 # writes. alloy and shop are that part, and a LANGS=rust image now builds
2184 # both. The Go tier still needs LANGS=rust,go and the C tier still has no
2185 # dial.
2186 RUN set -eu; \
2187 for lang in $(echo "$LANGS" | tr ',' ' '); do \
2188 case "$lang" in \
2189 rust) dnf install -y rust cargo \
2190 wayland-devel libxkbcommon-devel fontconfig-devel ;; \
2191 c) dnf install -y --setopt=install_weak_deps=False \
2192 gcc gcc-c++ make cmake meson ninja-build \
2193 autoconf automake libtool patch pkgconf ;; \
2194 go) dnf install -y golang ;; \
2195 python) dnf install -y python3 python3-pip ;; \
2196 zig) dnf install -y zig ;; \
2197 js) dnf install -y --setopt=install_weak_deps=False nodejs npm ;; \
2198 esac; \
2199 done; \
2200 dnf clean all; \
2201 for lang in $(echo "$LANGS" | tr ',' ' '); do \
2202 case "$lang" in \
2203 rust) command -v cargo >/dev/null || { echo "rust was asked for and cargo is not in the image" >&2; exit 1; }; \
2204 for mod in wayland-client xkbcommon fontconfig; do \
2205 pkg-config --exists "$mod" \
2206 || { echo "rust was asked for and $mod has no pkgconfig file; shop cannot be rebuilt on this image" >&2; exit 1; }; \
2207 done ;; \
2208 c) for t in gcc g++ make cmake meson; do \
2209 command -v "$t" >/dev/null \
2210 || { echo "c was asked for and $t is not in the image" >&2; exit 1; }; \
2211 done; \
2212 command -v ninja >/dev/null || command -v ninja-build >/dev/null \
2213 || { echo "c was asked for and ninja is not in the image" >&2; exit 1; } ;; \
2214 go) command -v go >/dev/null || { echo "go was asked for and the go binary is not in the image" >&2; exit 1; } ;; \
2215 python) command -v python3 >/dev/null || { echo "python was asked for and python3 is not in the image" >&2; exit 1; } ;; \
2216 zig) command -v zig >/dev/null || { echo "zig was asked for and the zig binary is not in the image" >&2; exit 1; } ;; \
2217 js) command -v node >/dev/null || { echo "js was asked for and node is not in the image" >&2; exit 1; }; \
2218 command -v npm >/dev/null || { echo "js was asked for and npm is not in the image" >&2; exit 1; } ;; \
2219 esac; \
2220 done; \
2221 echo "languages: ${LANGS:-none}"
2222
2223 # =====================================================================
2224 # The database, from the DB choice.
2225 # =====================================================================
2226 # `ARG DB` above argues the version: this installs postgresql16 rather than
2227 # Fedora's default postgresql-server, because the scratch cluster Sando runs
2228 # has to be the major production runs. The packages, the weight and the
2229 # measurement date are on the ARG.
2230 #
2231 # Binaries only. Nothing here enables a unit or runs initdb, so a mint with
2232 # DB=postgres16 boots with psql and postgres on PATH and no cluster. Creating
2233 # one is the machine's business.
2234 #
2235 # Asserted on both arms, the way the language block above is: `none` has to
2236 # stay empty, or the dial is decorative and an image is carrying a database
2237 # nobody asked for.
2238 RUN set -eu; \
2239 case "$DB" in \
2240 postgres16) \
2241 dnf install -y --setopt=install_weak_deps=False \
2242 postgresql16 postgresql16-server postgresql16-contrib; \
2243 dnf clean all; \
2244 command -v psql >/dev/null \
2245 || { echo "DB=postgres16 was asked for and psql is not in the image" >&2; exit 1; }; \
2246 command -v postgres >/dev/null \
2247 || { echo "DB=postgres16 was asked for and postgres is not in the image" >&2; exit 1; }; \
2248 psql --version | grep -q " 16\." \
2249 || { echo "DB=postgres16 installed $(psql --version), which is not the major production runs" >&2; exit 1; }; \
2250 # The extensions a production dump declares, without which a restore
2251 # stops at the first CREATE EXTENSION and Sando's migration_check and
2252 # cargo_test gates cannot run at all. Measured 2026-09-04 against
2253 # /srv/sando/backups: the server dump needs pgcrypto and pg_trgm, the
2254 # multithreaded one pg_trgm. They ship in postgresql16-contrib, and the
2255 # unversioned postgresql-contrib is 18.6 on this base, which is the
2256 # wrong major and the trap this asserts against.
2257 for ext in pgcrypto pg_trgm; do \
2258 find / -name "$ext.control" -path "*/extension/*" 2>/dev/null | grep -q . \
2259 || { echo "DB=postgres16 was asked for and $ext.control is not in the image; a prod dump would fail to restore" >&2; exit 1; }; \
2260 done; \
2261 # The RPM's %post leaves a regular file in /var, and /var is per-machine
2262 # state on a bootc system: `bootc container lint` fails the build on a
2263 # non-directory there, because tmpfiles.d has no type that describes an
2264 # existing file's contents and so nothing can declare it. Moved under
2265 # /usr, where image content belongs; 50-alloy-var-postgres.conf copies it
2266 # back on first boot. Not deleted, because it exports PGDATA and `su -
2267 # postgres` is how a scratch cluster gets restored into.
2268 install -D -m 0644 -o root -g root \
2269 /var/lib/pgsql/.bash_profile /usr/share/alloy/pgsql/bash_profile; \
2270 rm -f /var/lib/pgsql/.bash_profile; \
2271 grep -q '^export PGDATA$' /usr/share/alloy/pgsql/bash_profile \
2272 || { echo "postgres .bash_profile no longer exports PGDATA; the tmpfiles C line would restore a file that does nothing" >&2; exit 1; }; \
2273 [ ! -e /var/lib/pgsql/.bash_profile ] \
2274 || { echo "postgres .bash_profile is still in /var; bootc lint will refuse the image" >&2; exit 1; } \
2275 ;; \
2276 none) \
2277 ! command -v psql >/dev/null \
2278 || { echo "DB=none and psql is in the image anyway" >&2; exit 1; }; \
2279 ! command -v postgres >/dev/null \
2280 || { echo "DB=none and postgres is in the image anyway" >&2; exit 1; } \
2281 ;; \
2282 *) echo "unknown DB '$DB' reached the install arm; the validator did not run" >&2; exit 1 ;; \
2283 esac; \
2284 echo "db: $DB"
2285
2286 # =====================================================================
2287 # The desktop-app build set, from the GUI choice.
2288 #
2289 # `ARG GUI` above argues why this is a dial. This is the Fedora spelling of the
2290 # list goingson's README gives for Debian: webkit2gtk4.1-devel is the one that
2291 # matters and the rest are what cargo-tauri and linuxdeploy reach for.
2292 # libxdo-devel is the Fedora name for Debian's libxdo-dev, and
2293 # libappindicator-gtk3-devel for libayatana-appindicator3-dev.
2294 #
2295 # Weak deps are left ON here, unlike every other install in this file. A -devel
2296 # package's recommends are the headers and pkgconfig files its own dependents
2297 # need, and dropping them produces a set that installs cleanly and then fails at
2298 # `pkg-config --exists` for a library that is on disk. The cost is measured
2299 # rather than assumed by the assertion below, which is the real gate: pkg-config
2300 # has to find webkit2gtk-4.1, because that is what the Tauri build actually asks.
2301 #
2302 # No toolchain here. Rust arrives through LANGS and the Tauri CLI is a cargo
2303 # install into $HOME, which is machine state; this arm is the system libraries
2304 # and stops, the same way the database arm puts binaries on PATH and stops.
2305 RUN set -eu; \
2306 case "$GUI" in \
2307 tauri) \
2308 dnf install -y \
2309 webkit2gtk4.1-devel gtk3-devel libsoup3-devel openssl-devel \
2310 librsvg2-devel libappindicator-gtk3-devel libxdo-devel; \
2311 dnf clean all; \
2312 for mod in webkit2gtk-4.1 gtk+-3.0 libsoup-3.0 openssl librsvg-2.0; do \
2313 pkg-config --exists "$mod" \
2314 || { echo "GUI=tauri was asked for and pkg-config cannot find $mod; a Tauri build would fail at configure time" >&2; exit 1; }; \
2315 done; \
2316 echo "gui: tauri, webkit2gtk-4.1 $(pkg-config --modversion webkit2gtk-4.1)" \
2317 ;; \
2318 none) \
2319 ! pkg-config --exists webkit2gtk-4.1 2>/dev/null \
2320 || { echo "GUI=none and the webkit development set is in the image anyway" >&2; exit 1; }; \
2321 echo "gui: none" \
2322 ;; \
2323 *) echo "unknown GUI '$GUI' reached the install arm; the validator did not run" >&2; exit 1 ;; \
2324 esac
2325
2326 # =====================================================================
2327 # Printing — driverless only, and deliberately in its own layer.
2328 #
2329 # Alloy prints to IPP Everywhere devices and nothing else. That is the
2330 # whole position: every printer sold since roughly 2015 speaks it, the
2331 # printer advertises its own capabilities, and there is no driver to
2332 # pick, install or match. A printer that needs a vendor driver is not
2333 # supported, stated plainly rather than left to be discovered.
2334 #
2335 # What that buys is the absence of the usual printing stack:
2336 # foomatic-db is thousands of PPDs, gutenprint and hplip are vendor
2337 # driver estates, and none of them is reachable from a driverless-only
2338 # position. Verified against the resolved transaction rather than
2339 # assumed — none of the three appears. ghostscript does, as a hard
2340 # dependency of cups-filters for rasterizing, and is accepted.
2341 #
2342 # The prerequisite was already in place, which is what makes this cheap:
2343 # discovery is mDNS, and nss-mdns is installed with /etc/nsswitch.conf
2344 # already resolving .local (see the avahi block). So a driverless
2345 # printer appears by itself and most users never configure anything.
2346 #
2347 # Weak dependencies off. This was a separate `dnf install` because the
2348 # client block above did not pass the flag, so folding these lines in
2349 # would have taken the larger set silently: on the default
2350 # `install_weak_deps=True` this transaction is 52 packages instead of
2351 # 33, and the extra 19 are recommends nothing here asked for. That gap
2352 # closed on 2026-08-18 and the client block passes the flag too, so the
2353 # split is no longer load-bearing for this reason. It stays because the
2354 # assertions below are printing's and belong beside printing's install,
2355 # and because a layer per subsystem is what the rest of this file does.
2356 #
2357 # No scanning. sane-backends is per-device backends and USB permission
2358 # work, its failures are opaque, and a scanner is far rarer on a laptop
2359 # than a printer. Recorded as a rejection in docs/STACK.md rather than
2360 # left as silence, per principle 5.
2361 #
2362 # The surface is CUPS' own web UI on localhost:631. It needs no GTK and
2363 # costs nothing when unused, which matters because socket activation
2364 # means the daemon is not running until something connects.
2365 # =====================================================================
2366 # Client-only, ruled by Max 2026-08-01. The driverless-only position above
2367 # rests on a laptop with a person at it and a printer on the same subnet;
2368 # neither reaches a headless box, and a spooler nothing prints to is surface
2369 # with no user. The preset lines for cups.socket and cups.path go with it.
2370 RUN if [ "$PROFILE" = client ]; then \
2371 dnf install -y --setopt=install_weak_deps=False \
2372 cups \
2373 cups-filters \
2374 && dnf clean all; \
2375 for unwanted in foomatic-db gutenprint hplip; do \
2376 rpm -q "$unwanted" >/dev/null 2>&1 \
2377 && { echo "$unwanted arrived with the printing stack; the driverless-only position is no longer true" >&2; exit 1; }; \
2378 done; \
2379 test -f /usr/lib/systemd/system/cups.socket \
2380 || { echo "cups ships no socket unit; the preset would enable nothing and printing would need a running daemon" >&2; exit 1; }; \
2381 echo "printing: driverless only, no vendor driver packages"; \
2382 else \
2383 rpm -q cups >/dev/null 2>&1 \
2384 && { echo "profile=server but cups is installed; something pulled in the print spooler" >&2; exit 1; }; \
2385 echo "printing: none on this profile"; \
2386 fi
2387
2388 # SwayOSD ships its system unit where systemd does not look.
2389 #
2390 # Fedora's SwayOSD-0.3.2 installs swayosd-libinput-backend.service into
2391 # /usr/lib64/systemd/system/. systemd's system unit search path is
2392 # /usr/local/lib/systemd/system and /usr/lib/systemd/system, and on Fedora
2393 # /usr/lib64 is a real directory rather than a symlink to /usr/lib, so the unit
2394 # is invisible. `enable swayosd-libinput-backend.service` in
2395 # etc/systemd/system-preset/50-alloy.preset therefore matched nothing and was
2396 # ignored without a word — the same silent-no-op this project already documented
2397 # for template units, arrived at by a different route.
2398 #
2399 # The visible symptom is the one the preset was added to fix on 2026-07-21: the
2400 # sway config's `bindsym --release Caps_Lock` and Num_Lock overlays have nothing
2401 # running behind them, because the libinput backend that watches modifier state
2402 # is a system service and never started.
2403 #
2404 # Copied rather than symlinked so the unit survives the package moving it, and
2405 # asserted first so that the day Fedora fixes the packaging this build fails
2406 # loudly instead of silently installing a stale duplicate.
2407 #
2408 # Client-only because swayosd is: the package is what ships the misplaced
2409 # unit, so on `server` there is nothing to relocate. The `else` still checks
2410 # rather than skipping, which is what catches the unit arriving as somebody's
2411 # dependency and being copied onto a machine with no compositor to drive it.
2412 RUN if [ "$PROFILE" = client ]; then \
2413 test -f /usr/lib64/systemd/system/swayosd-libinput-backend.service \
2414 || { echo "SwayOSD unit is no longer in /usr/lib64 — drop this workaround"; exit 1; }; \
2415 test ! -f /usr/lib/systemd/system/swayosd-libinput-backend.service \
2416 || { echo "SwayOSD now ships the unit correctly — drop this workaround"; exit 1; }; \
2417 cp /usr/lib64/systemd/system/swayosd-libinput-backend.service \
2418 /usr/lib/systemd/system/swayosd-libinput-backend.service; \
2419 else \
2420 test ! -e /usr/lib64/systemd/system/swayosd-libinput-backend.service \
2421 || { echo "profile=server but the SwayOSD unit is present; swayosd got installed" >&2; exit 1; }; \
2422 fi
2423
2424 # The same packaging bug again, in udev this time, and it is why the brightness
2425 # keys do nothing.
2426 #
2427 # Found on real hardware 2026-07-29: the volume Fn keys show an OSD and the
2428 # brightness ones do nothing at all, silently. SwayOSD raises brightness by
2429 # writing /sys/class/backlight/<dev>/brightness directly, which ships
2430 # root-owned 0644. The package knows this and ships the rule that fixes it:
2431 #
2432 # ACTION=="add", SUBSYSTEM=="backlight", RUN+="/bin/chgrp video /sys/class/backlight/%k/brightness"
2433 # ACTION=="add", SUBSYSTEM=="backlight", RUN+="/bin/chmod g+w /sys/class/backlight/%k/brightness"
2434 #
2435 # It ships it at /usr/lib64/udev/rules.d/99-swayosd.rules. udev reads
2436 # /usr/lib/udev/rules.d, /run/udev/rules.d and /etc/udev/rules.d, and on this
2437 # base /usr/lib64/udev is a real directory rather than a symlink, so the rule
2438 # never fires and the permissions never change. Same defect as the unit above,
2439 # same cause, different subsystem.
2440 #
2441 # Why it failed silently rather than logging: swayosd-server is started by
2442 # `exec swayosd-server` from the sway config, so its stderr goes nowhere a
2443 # journal can see it. A permission error on the sysfs write has no reader.
2444 #
2445 # Copied rather than symlinked, and asserted first, for the same reasons as the
2446 # unit: the copy survives the package moving the file, and the day Fedora fixes
2447 # the path this build fails loudly instead of installing a stale duplicate that
2448 # quietly disagrees with the packaged one.
2449 #
2450 # This is half the fix. The rule chgrp's to `video`, and a group with no members
2451 # grants nothing, so the installer puts the account in it — see the `useradd`
2452 # stage in crates/alloy/src/install.rs. Either half alone leaves brightness
2453 # broken, which is why the group membership is asserted further down rather than
2454 # left to be discovered on a booted machine.
2455 #
2456 # Client-only for the same reason as the unit above, and with the same
2457 # asserting else. A backlight is a screen, and a headless box has neither.
2458 RUN set -eu; \
2459 packaged=/usr/lib64/udev/rules.d/99-swayosd.rules; \
2460 canon=/usr/lib/udev/rules.d/99-swayosd.rules; \
2461 if [ "$PROFILE" = client ]; then \
2462 set -x; \
2463 [ -f "$packaged" ] \
2464 || { echo "SwayOSD no longer ships $packaged — re-check where the backlight rule went" >&2; exit 1; }; \
2465 [ ! -f "$canon" ] \
2466 || { echo "SwayOSD now ships the udev rule on udev's search path — drop this workaround" >&2; exit 1; }; \
2467 grep -q 'SUBSYSTEM=="backlight"' "$packaged" \
2468 || { echo "$packaged no longer matches the backlight subsystem; copying it would fix nothing" >&2; exit 1; }; \
2469 grep -q 'chgrp video' "$packaged" \
2470 || { echo "$packaged no longer chgrp's to video; the installer's group grant is now the wrong group" >&2; exit 1; }; \
2471 mkdir -p /usr/lib/udev/rules.d; \
2472 cp "$packaged" "$canon"; \
2473 else \
2474 [ ! -e "$packaged" ] && [ ! -e "$canon" ] \
2475 || { echo "profile=server but the SwayOSD backlight rule is present" >&2; exit 1; }; \
2476 fi
2477
2478 # distrobox is pinned. v2 is a Go rewrite, at rc.3 as of 2026-06-29, and
2479 # upstream's own announcement says v1 stays the production recommendation
2480 # and that exported binaries and apps must be re-exported after
2481 # upgrading. So the pin excludes v2, and nothing narrower.
2482 #
2483 # The v1 *line*, not a build. This read `distrobox-1.8.2.5*` and would have
2484 # hard-failed the whole image build on an upstream event: Fedora's updates
2485 # repo keeps only the newest build, and dnf5 `install` is fatal on an argument
2486 # that matches nothing, so the day 1.8.3 lands the build stops with no local
2487 # change. Moving off the 1.8 line wants a release note; moving within it does
2488 # not, and should not break the build.
2489 RUN dnf install -y 'distrobox-1.8*' \
2490 && dnf clean all
2491
2492 # =====================================================================
2493 # Package removals — stock desktop pieces Alloy replaces
2494 # =====================================================================
2495 # fedora-bootc:43 is minimal and probably ships none of these, but keep
2496 # the remove line for safety in case the base grows. || true swallows
2497 # the "package not installed" error path.
2498 RUN dnf remove -y \
2499 gdm \
2500 gnome-shell \
2501 gnome-session \
2502 # foot, a second terminal nothing in Alloy invokes. It arrives as a
2503 # weak dependency of sway-config-upstream, whose config binds it as
2504 # sway's default terminal; Alloy ships its own config and binds shop
2505 # (docs/STACK.md#terminal), so nothing here ever launches it. Left in,
2506 # it puts three of the eleven entries in the new launcher (Foot, Foot
2507 # Client, Foot Server), which is how an unused package stops being
2508 # merely 0.8 MB of dead weight. Recommends are not requirements, so
2509 # this does not disturb sway-config-upstream itself.
2510 #
2511 # Since 2026-08-18 the client block turns weak deps off, so foot no
2512 # longer arrives and this line removes nothing. Kept anyway: `|| true`
2513 # already covers the not-installed path, it is the standing answer to
2514 # the day somebody hard-requires it, and a build that reintroduced it
2515 # would otherwise reintroduce the three launcher entries with it.
2516 foot \
2517 || true
2518
2519 # The base's own dead weight, under TRIM. The list and its cost are argued at
2520 # `ARG TRIM` above; this is only the removal.
2521 #
2522 # Both branches assert, the same way the PROFILE conditionals do. `unused`
2523 # proves the packages are really gone rather than trusting a glob that may
2524 # have matched nothing, and refuses to pass silently if it found none of them
2525 # to remove: an empty match means the base composition moved and this list is
2526 # describing a machine that no longer exists, which is the state a comment
2527 # would sit in for a year. Firmware is checked on BOTH branches, because the
2528 # thing that must stay true is that no trim ever reaches it.
2529 RUN set -eu; \
2530 if [ "$TRIM" = unused ]; then \
2531 list="python3-botocore toolbox qemu-user-static*"; \
2532 gone="$(rpm -qa --qf '%{NAME}\n' $list)"; \
2533 [ -n "$gone" ] \
2534 || { echo "TRIM=unused matched none of the packages it removes; the base changed and the list at ARG TRIM needs re-measuring" >&2; exit 1; }; \
2535 dnf remove -y $gone; \
2536 for pkg in python3-botocore toolbox; do \
2537 ! rpm -q "$pkg" >/dev/null 2>&1 \
2538 || { echo "$pkg survived the trim" >&2; exit 1; }; \
2539 done; \
2540 echo "trim: removed $(echo "$gone" | wc -l) base packages nothing in Alloy reaches"; \
2541 else \
2542 echo "trim: none, the base ships as it comes"; \
2543 fi; \
2544 for fw in linux-firmware nvidia-gpu-firmware; do \
2545 rpm -q "$fw" >/dev/null 2>&1 \
2546 || { echo "$fw is not in this image; a medium built here cannot drive hardware nobody asked about at build time, and the trim must never reach firmware" >&2; exit 1; }; \
2547 done
2548
2549 # The language and documentation macros, proved to have taken. They are set
2550 # hundreds of lines above and bind only what is installed after them, so a
2551 # silent no-op here looks exactly like success: the build passes, the image is
2552 # a quarter of a gigabyte bigger than it should be, and nobody notices for a
2553 # year. This runs after the last `dnf install` in the stage for that reason.
2554 #
2555 # The gate is `/usr/share/locale` against the baseline the macro layer
2556 # recorded, plus a wide margin. Four packages alone grew it by 112 MB with the
2557 # macros off and by 0.5 MB with them on, so 32 MiB of headroom is unreachable
2558 # by a correct build and unmissable by a broken one. Reading the baseline at
2559 # build time rather than hardcoding it keeps this honest when the FROM line
2560 # moves and the base's own contribution with it.
2561 #
2562 # Both branches assert, the same way the trim above does. `keep` proves the
2563 # macro file is really absent rather than trusting that the conditional took
2564 # the branch it was asked for.
2565 #
2566 # Licences are checked on both branches, for the reason given at the macro
2567 # layer: rpm treats %license separately from %doc today, and an upstream
2568 # change that swept them together would be a licensing problem rather than a
2569 # size one.
2570 RUN set -eu; \
2571 baseline="$(cat /usr/lib/alloy/.locale-baseline)"; \
2572 rm -f /usr/lib/alloy/.locale-baseline; \
2573 now="$(du -sb /usr/share/locale | cut -f1)"; \
2574 grown=$(( now - baseline )); \
2575 [ -n "$(ls -A /usr/share/licenses 2>/dev/null)" ] \
2576 || { echo "/usr/share/licenses is empty; %license was swept along with %doc and crates/alloy/credits.toml no longer describes what the image ships" >&2; exit 1; }; \
2577 if [ "$TRIM" = unused ]; then \
2578 [ "$grown" -le 33554432 ] \
2579 || { echo "/usr/share/locale grew $((grown / 1048576)) MB over the base, so the language macro did not take. The likely cause is a dnf install that moved above the layer setting it." >&2; exit 1; }; \
2580 [ -f /etc/rpm/macros.image-language-conf ] \
2581 || { echo "the language macro file is gone; something below the macro layer removed it" >&2; exit 1; }; \
2582 grep -q '^tsflags=nodocs$' /etc/dnf/dnf.conf \
2583 || { echo "tsflags=nodocs is no longer in /etc/dnf/dnf.conf; documentation was installed from wherever it was dropped onward" >&2; exit 1; }; \
2584 else \
2585 [ ! -f /etc/rpm/macros.image-language-conf ] \
2586 || { echo "TRIM=keep but the language macro is set; the macro layer ran the wrong branch" >&2; exit 1; }; \
2587 fi; \
2588 echo "langs: /usr/share/locale grew $((grown / 1048576)) MB over the base"
2589
2590 # =====================================================================
2591 # Register nushell as a legitimate login shell.
2592 # =====================================================================
2593 # The installer sets new accounts to /usr/bin/nu (crates/alloy/src/
2594 # install.rs, LOGIN_SHELL), which is the shell etc/skel/.config/nushell/
2595 # is written for. Fedora's nushell package does not add itself to
2596 # /etc/shells, and an unlisted shell is a second-class one: chsh refuses
2597 # it, so the account's owner cannot switch away from or back to it, and
2598 # anything consulting /etc/shells treats the account as restricted.
2599 #
2600 # The installer reads this file and refuses to create an account if the
2601 # shell is missing from it, so this line is load-bearing rather than
2602 # tidy: without it every install stops with "not listed in the target's
2603 # /etc/shells". bash stays at /bin/sh and /bin/bash for scripts.
2604 RUN echo /usr/bin/nu >> /etc/shells
2605
2606 # =====================================================================
2607 # Shell integrations, generated once into nushell's system autoload dir.
2608 # =====================================================================
2609 # nushell sources every .nu in its autoload directories at startup, and
2610 # /usr/share/nushell/vendor/autoload is the first one it looks in. Files
2611 # put here are picked up by every account with no per-user setup, which
2612 # is what an image with a read-only /usr wants.
2613 #
2614 # Generated at build time rather than on first shell start, for two
2615 # reasons that only showed up when this was tried the other way round.
2616 # `source` is a nushell *parse-time* keyword: it takes a path known while
2617 # the file is being parsed, so `let cache = ...; source $cache` fails with
2618 # "Value is not a parse-time constant" and, because the failure is a parse
2619 # error, nushell discards the whole config file. The account then gets
2620 # stock nushell: no theme, no vi mode, no history settings, no aliases,
2621 # and the banner that config.nu turns off. The second reason is cost:
2622 # each init spawns a process to re-render output that cannot change
2623 # between shells on a read-only /usr.
2624 #
2625 # Load order, measured on nushell 0.112.2 rather than assumed: env.nu,
2626 # then config.nu, then the vendor autoload dirs, then the user's. The
2627 # autoload pass running *after* config.nu is load-bearing for the
2628 # hand-authored direnv.nu that the config tree below drops into this same
2629 # directory. config.nu assigns `$env.config` wholesale, hooks included, so
2630 # a hook registered any earlier would be discarded before it ever ran.
2631 #
2632 # Guarded on the binaries so this step does not fail the build if either
2633 # package leaves the image; nushell simply autoloads whatever is here.
2634 RUN mkdir -p /usr/share/nushell/vendor/autoload \
2635 && if command -v starship >/dev/null; then \
2636 starship init nu > /usr/share/nushell/vendor/autoload/starship.nu; \
2637 fi \
2638 && if command -v zoxide >/dev/null; then \
2639 zoxide init nushell > /usr/share/nushell/vendor/autoload/zoxide.nu; \
2640 fi
2641
2642 # =====================================================================
2643 # Config tree — the etc/ and usr/ trees in the repo map 1:1 into
2644 # the image. Per-user defaults live under etc/skel/.config/;
2645 # system-wide config under etc/. See docs/IMAGE.md for the layout.
2646 #
2647 # Two sources, in this order. The repo tree is everything whose content is
2648 # fixed; the rendered tree is everything that carries a color, which is not in
2649 # the repo at all — templates/ holds those files with their palette left as
2650 # tokens. The generated tree mirrors `/` the same way, so it lands by the same
2651 # 1:1 rule and simply completes the tree rather than patching it: no file
2652 # appears in both, and a stale copy of a themed file cannot shadow its
2653 # generated version because there is no copy to go stale. "No file appears in
2654 # both" is checked in the rust-build stage rather than assumed here, since this
2655 # is the stage where a collision would resolve quietly in the render's favor.
2656 # =====================================================================
2657 COPY etc/ /etc/
2658 COPY usr/ /usr/
2659 COPY --from=rust-build /staged-skel/ /
2660
2661 # The backdrop's loop. A COPY into /usr/bin, which the shop stage above calls
2662 # the worst of the shapes for anything hotfixable -- an unowned file cannot even
2663 # be layered over. It is the right shape here anyway, and for the same reason
2664 # the sibling helpers beside it arrive by `COPY usr/ /usr/`: the backdrop must
2665 # draw on the first boot, before alloy-layer-components.service has laid the
2666 # console down, because that is the boot where somebody most needs to be told
2667 # which keys exist. Something that has to be present before layering cannot be
2668 # delivered by layering.
2669 COPY --from=rust-build /src/target/release/alloy-drift /usr/bin/alloy-drift
2670
2671 # =====================================================================
2672 # dconf: the GTK schema defaults, compiled and proven to answer.
2673 # =====================================================================
2674 # Alloy's configuration story is files in etc/skel a person can read and edit.
2675 # A compiled binary database is a second kind of thing, and this is the whole
2676 # of it: GTK/GNOME schema defaults have no file form a user could edit
2677 # instead, so a machine-wide default has to be a dconf keyfile or it cannot
2678 # exist. What it holds is argued in `etc/dconf/db/local.d/`; wiki
2679 # `alloy-packaging-policy` says what may go in and what may not.
2680 #
2681 # The profile puts `user-db:user` above `system-db:local`, so a person who
2682 # sets a key the other way wins and stays winning. That is why a system
2683 # default is the correct shape here and why writing into the user's own dconf
2684 # from `alloy-session` was refused: this can be overridden, that would
2685 # override.
2686 #
2687 # THE ASSERTION IS THE POINT. `dconf update` exits 0 when it compiles nothing,
2688 # so a keyfile in the wrong directory, a bad group name, or a missing profile
2689 # all fail silently and leave a defaults mechanism that quietly stopped
2690 # applying. So the check is a real read through the profile rather than a test
2691 # for the file: `dconf read` consults the databases directly and needs no
2692 # session bus, which is what makes it available at build time.
2693 RUN set -eu; \
2694 if [ "$PROFILE" = client ]; then \
2695 command -v dconf >/dev/null \
2696 || { echo "dconf is not in the image; /etc/dconf/db/local cannot be compiled and the GTK defaults would be inert" >&2; exit 1; }; \
2697 test -f /etc/dconf/profile/user \
2698 || { echo "/etc/dconf/profile/user did not land; with no profile dconf reads the user database alone and db/local is never consulted" >&2; exit 1; }; \
2699 dconf update; \
2700 test -s /etc/dconf/db/local \
2701 || { echo "dconf update compiled nothing; db/local.d is empty or unreadable" >&2; exit 1; }; \
2702 for key in /org/gtk/settings/file-chooser/show-hidden /org/gtk/gtk4/settings/file-chooser/show-hidden; do \
2703 [ "$(dconf read "$key")" = true ] \
2704 || { echo "$key does not read true out of the compiled database; the GTK file chooser would hide dotfiles" >&2; exit 1; }; \
2705 done; \
2706 echo "dconf: $(ls /etc/dconf/db/local.d | wc -l) keyfile(s) compiled, GTK3 and GTK4 file choosers show dotfiles"; \
2707 else \
2708 echo "dconf: keyfiles ride along inert on a profile with no session"; \
2709 fi
2710
2711 # =====================================================================
2712 # What has to reach a machine that UPGRADED into this image, not only
2713 # one installed from it: the /var declarations and the greeter account.
2714 # =====================================================================
2715 # Both halves are here for one reason. /var and /etc are per-machine on a bootc
2716 # system: `bootc install` copies the image's copies into the stateroot, and an
2717 # upgrade re-syncs /usr and leaves both alone. So anything that exists only
2718 # because a package's %post or a `useradd` ran during a build lands on fresh
2719 # installs and on nothing else. systemd-tmpfiles and systemd-sysusers run at
2720 # every boot, and a declaration each is what closes the gap. `bootc container
2721 # lint` is what names the two cases, under `var-tmpfiles` and `sysusers`, and
2722 # since 2026-08-26 the lint at the bottom of this file is fatal — so a package
2723 # added later that drops a new /var directory or a new account stops the build
2724 # here rather than warning into a log nobody reads.
2725 #
2726 # The account is created here rather than up beside the greetd install because
2727 # its definition arrives with `COPY usr/ /usr/` just above, and nothing between
2728 # the two needs it. It was a bare `useradd` until 2026-08-26; the sysusers file
2729 # is now the single definition, and this runs it.
2730 #
2731 # The client half is a separate file because it cannot merely be inert on
2732 # `server`: the three greetd lines name a user that arrives with a package this
2733 # profile does not install, and systemd-tmpfiles fails a line whose user does not
2734 # resolve. So it is deleted rather than shipped and ignored, and the `else`
2735 # proves the deletion happened.
2736 #
2737 # --dry-run --create on each file is the check that matters, and it is the same
2738 # one the flatpak rule gets further down: a tmpfiles line with a typo in it is
2739 # accepted by every build step except the boot it silently does nothing on.
2740 #
2741 # THE GEOCLUE HALF KEYS ON THE IMAGE, not on a dial, and that is the whole point
2742 # of it being separate. geoclue arrives with the GTK and WebKit stack, so it is
2743 # on every client and also on a server that sets GUI=tauri; its line was in the
2744 # client file, which a server deletes whole, and the first astra mint failed
2745 # bootc's var-tmpfiles lint on exactly that path. Keying the fix on `$PROFILE`
2746 # and `$GUI` would answer today's two reasons and be wrong about the third, so
2747 # the condition asks the image whether the account exists. A declaration naming a
2748 # user that is not there fails at boot, and one missing for a user that is there
2749 # fails the lint, so both directions are asserted rather than one.
2750 RUN set -eux; \
2751 greeter=/usr/lib/sysusers.d/50-alloy-greeter.conf; \
2752 common=/usr/lib/tmpfiles.d/50-alloy-var.conf; \
2753 client=/usr/lib/tmpfiles.d/50-alloy-var-client.conf; \
2754 [ -f "$common" ] || { echo "$common did not land; every /var directory in this image is undeclared" >&2; exit 1; }; \
2755 systemd-tmpfiles --dry-run --create "$common" >/dev/null \
2756 || { echo "systemd-tmpfiles rejects $common" >&2; exit 1; }; \
2757 if [ "$PROFILE" = client ]; then \
2758 [ -f "$client" ] || { echo "$client did not land" >&2; exit 1; }; \
2759 systemd-tmpfiles --dry-run --create "$client" >/dev/null \
2760 || { echo "systemd-tmpfiles rejects $client" >&2; exit 1; }; \
2761 grep -q '^L .*/xdg-desktop-portal\.service .*/dev/null$' "$client" \
2762 || { echo "$client no longer masks the greeter's xdg-desktop-portal; greetd's %post is the only thing writing that symlink and it runs on the build host" >&2; exit 1; }; \
2763 [ -f "$greeter" ] || { echo "$greeter did not land; the greeter account has no definition" >&2; exit 1; }; \
2764 systemd-sysusers "$greeter"; \
2765 getent passwd greeter >/dev/null \
2766 || { echo "systemd-sysusers accepted $greeter but created no greeter account; greetd would exit with 'configured default session user not found'" >&2; exit 1; }; \
2767 else \
2768 rm -f "$client" "$greeter"; \
2769 [ ! -e "$client" ] && [ ! -e "$greeter" ] \
2770 || { echo "profile=server still carries the client /var declarations or the greeter account definition, neither of which has packages here" >&2; exit 1; }; \
2771 getent passwd greeter >/dev/null \
2772 && { echo "profile=server has a greeter account but no greeter" >&2; exit 1; }; \
2773 echo "greeter: no account on this profile"; \
2774 fi; \
2775 geoclue=/usr/lib/tmpfiles.d/50-alloy-var-geoclue.conf; \
2776 if getent passwd geoclue >/dev/null; then \
2777 [ -f "$geoclue" ] || { echo "$geoclue did not land and this image has geoclue; /var/lib/geoclue would be undeclared and bootc lint refuses that" >&2; exit 1; }; \
2778 systemd-tmpfiles --dry-run --create "$geoclue" >/dev/null \
2779 || { echo "systemd-tmpfiles rejects $geoclue" >&2; exit 1; }; \
2780 echo "var: geoclue is installed, so its declaration ships"; \
2781 else \
2782 rm -f "$geoclue"; \
2783 [ ! -e "$geoclue" ] \
2784 || { echo "this image has no geoclue account and still carries its /var declaration, whose user systemd-tmpfiles cannot resolve at boot" >&2; exit 1; }; \
2785 echo "var: no geoclue on this image, so no declaration for it"; \
2786 fi; \
2787 echo "var: tmpfiles declarations present and accepted for profile=$PROFILE"
2788
2789 # The database's /var, on the same rule as the client half above.
2790 #
2791 # Separate from that block because it keys on $DB rather than $PROFILE: fw13 is
2792 # a client with a database and astra is a server with one, so the two dials do
2793 # not nest. Same shape otherwise — the file is deleted rather than shipped inert
2794 # on DB=none, because every line in it names the `postgres` user and
2795 # systemd-tmpfiles fails a line whose user does not resolve.
2796 #
2797 # The --dry-run --create is the check that matters, for the reason the block
2798 # above gives: a typo in a tmpfiles line is accepted everywhere except the boot
2799 # it silently does nothing on. Here it also proves the `postgres` user actually
2800 # resolves in this image, which is the half that would otherwise only fail on a
2801 # machine.
2802 RUN set -eux; \
2803 pg=/usr/lib/tmpfiles.d/50-alloy-var-postgres.conf; \
2804 if [ "$DB" = none ]; then \
2805 rm -f "$pg"; \
2806 [ ! -e "$pg" ] \
2807 || { echo "DB=none still carries the postgres /var declarations, whose user this image has no packages to create" >&2; exit 1; }; \
2808 echo "var: no database declarations on DB=none"; \
2809 else \
2810 [ -f "$pg" ] || { echo "$pg did not land; postgres /var would be undeclared and bootc lint refuses that" >&2; exit 1; }; \
2811 getent passwd postgres >/dev/null \
2812 || { echo "$pg names the postgres user and this image has no such account" >&2; exit 1; }; \
2813 systemd-tmpfiles --dry-run --create "$pg" >/dev/null \
2814 || { echo "systemd-tmpfiles rejects $pg" >&2; exit 1; }; \
2815 test -f /usr/share/alloy/pgsql/bash_profile \
2816 || { echo "$pg copies a bash_profile out of /usr and it is not there" >&2; exit 1; }; \
2817 echo "var: postgres declarations present and accepted for db=$DB"; \
2818 fi
2819
2820 # =====================================================================
2821 # The Firefox configuration is what pays for picking Firefox.
2822 # =====================================================================
2823 # Alloy picks a browser and defends it (wiki `alloy-byo-principle`), and a
2824 # default has to earn that under rule 3 of `alloy-packaging-policy`. Stock
2825 # Firefox does not earn it: telemetry, sponsored placements and tracking
2826 # protection at "standard" all serve someone other than the person running
2827 # it. /etc/firefox/pref/alloy.js is the payment, and without it this image
2828 # ships the exact thing docs/STACK.md calls indefensible.
2829 #
2830 # Fedora's firefox rpm owns /etc/firefox/pref and /etc is writable on a bootc
2831 # deployment, so this is one file and no write to /usr.
2832 #
2833 # THE THIRD CHECK IS THE INTERESTING ONE. The guard on that file is that it
2834 # only removes anti-features and restores hidden controls, never sets Alloy's
2835 # taste. `browser.uidensity` is the named example of the taste it may not
2836 # set, and `browser.compactmode.show` is the honest version of the same
2837 # want: put the control back and let the user choose. A guard nobody can
2838 # enforce is a comment, so it is asserted here instead. If this fails, read
2839 # the ladder in the wiki note before deleting the check.
2840 RUN set -eu; \
2841 conf=/etc/firefox/pref/alloy.js; \
2842 if [ "$PROFILE" = client ]; then \
2843 if [ "$BROWSER" = firefox ]; then \
2844 test -f "$conf" \
2845 || { echo "browser=firefox and $conf is not in the image; the pick is unpaid for and this image ships stock Firefox" >&2; exit 1; }; \
2846 grep -q '^pref("browser.ml.enable", false);' "$conf" \
2847 || { echo "$conf no longer turns off browser.ml.enable; the AI block was the substantive half of this file" >&2; exit 1; }; \
2848 grep -q '^pref("browser.compactmode.show", true);' "$conf" \
2849 || { echo "$conf no longer restores the density control; rung 1 of the ladder is the cheapest thing this file does" >&2; exit 1; }; \
2850 echo "firefox: $(grep -c '^pref(' "$conf") prefs, anti-features and one restored control"; \
2851 else \
2852 rpm -q firefox >/dev/null 2>&1 \
2853 && { echo "browser=none and firefox is installed anyway; something pulled in a browser nobody chose" >&2; exit 1; }; \
2854 echo "firefox: not in this image, by choice; the config file rides along inert"; \
2855 fi; \
2856 else \
2857 rpm -q firefox >/dev/null 2>&1 \
2858 && { echo "profile=server carries firefox; the profile split leaked a browser onto an image with no session to run one in" >&2; exit 1; }; \
2859 echo "firefox: correctly absent on a profile with no session"; \
2860 fi; \
2861 if [ -f "$conf" ]; then \
2862 if grep -q '^pref("browser.uidensity"' "$conf"; then \
2863 echo "$conf sets browser.uidensity; that is rung 3, Alloy's taste in an app it configures only to remove anti-features. Restore the control, do not pick the value." >&2; exit 1; \
2864 fi; \
2865 if grep -qE '^[[:space:]]*(lockPref|defaultPref)\(' "$conf"; then \
2866 echo "$conf locks a pref; every line in it is meant to be a starting position the user can change, and a lock is Alloy hiding a control while objecting to hidden controls" >&2; exit 1; \
2867 fi; \
2868 echo "firefox config: no taste, no locks"; \
2869 fi
2870
2871 # =====================================================================
2872 # The cursor theme skel names has to be the one the prune kept.
2873 # =====================================================================
2874 # bibata-cursor-theme ships fourteen themes and the package block above
2875 # deletes thirteen of them, 159 MB, keeping the one string spelled there.
2876 # That prune runs 600 lines before skel exists, so it cannot read the name
2877 # it is keeping; this is the other end of that, and it is the only thing
2878 # standing between a rename and a session that starts with the X11 default
2879 # cursor because its theme directory was thrown away at build time.
2880 #
2881 # Read from skel rather than compared against a constant. Five files declare
2882 # the cursor and each has its own syntax, so a rename that reaches four of
2883 # them and not the fifth is the realistic failure, and it is one this catches
2884 # where a single hardcoded name would not.
2885 #
2886 # The second loop is the mirror: every theme still on disk must be one skel
2887 # asked for. It fails if the prune silently stopped running, which is the
2888 # case that costs 159 MB rather than a broken cursor, and which nothing else
2889 # here would notice.
2890 RUN set -eu; \
2891 if [ "$PROFILE" = client ]; then \
2892 names="$( { \
2893 sed -n 's/^[[:space:]]*Inherits[[:space:]]*=[[:space:]]*//p' \
2894 /etc/skel/.icons/default/index.theme; \
2895 sed -n 's/^[[:space:]]*gtk-cursor-theme-name[[:space:]]*=[[:space:]]*//p' \
2896 /etc/skel/.config/gtk-3.0/settings.ini \
2897 /etc/skel/.config/gtk-4.0/settings.ini; \
2898 sed -n 's/^[[:space:]]*seat[[:space:]]\+[^[:space:]]\+[[:space:]]\+xcursor_theme[[:space:]]\+\([^[:space:]]\+\).*/\1/p' \
2899 /etc/skel/.config/sway/config; \
2900 sed -n 's/^.*XCURSOR_THEME[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' \
2901 /etc/skel/.config/nushell/env.nu; \
2902 } | sed 's/[[:space:]]*$//' | sort -u )"; \
2903 [ -n "$names" ] \
2904 || { echo "no cursor theme is declared anywhere in /etc/skel; the five declarations moved and the prune in the package block is now keeping a directory nothing asks for" >&2; exit 1; }; \
2905 for n in $names; do \
2906 test -d "/usr/share/icons/$n" \
2907 || { echo "skel asks for cursor theme '$n' and /usr/share/icons/$n is not in the image; the prune in the package block kept a different name" >&2; exit 1; }; \
2908 done; \
2909 for theme in /usr/share/icons/Bibata-*; do \
2910 [ -d "$theme" ] || continue; \
2911 echo "$names" | grep -qx "$(basename "$theme")" \
2912 || { echo "$(basename "$theme") survived and skel does not name it; the prune in the package block stopped running and the image is carrying ~12-27 MB per unused theme" >&2; exit 1; }; \
2913 done; \
2914 echo "cursor: $(echo "$names" | tr '\n' ' ')kept, every other Bibata theme pruned"; \
2915 else \
2916 # The asserting else, same rule as the package block: a check that runs
2917 # on one profile and silently passes on the other is not a check. The
2918 # server claim is the stronger one — bibata-cursor-theme is named only
2919 # inside the client branch, so a server image should carry no Bibata
2920 # directory at all. If one is here, the graphical group leaked across
2921 # the profile split and 179 MB of cursors came with it, on an image with
2922 # no session to draw a cursor in.
2923 for theme in /usr/share/icons/Bibata-*; do \
2924 [ -d "$theme" ] || continue; \
2925 echo "$(basename "$theme") is in the server image; bibata-cursor-theme is named only in the client branch, so the profile split leaked" >&2; \
2926 exit 1; \
2927 done; \
2928 echo "cursor: none, as the server profile draws none"; \
2929 fi
2930
2931 # =====================================================================
2932 # The default web handler has to name something the image contains.
2933 # =====================================================================
2934 # /etc/xdg/mimeapps.list points http, https and text/html at alloy-open,
2935 # which finds whatever browser is installed and, when there is none, says so
2936 # rather than failing to stderr where nobody is looking. Three ways that can
2937 # rot silently, so all three are checked.
2938 #
2939 # The vendor file is the cautionary tale this replaces: shared-mime-info ships
2940 # /usr/share/applications/mimeapps.list naming `org.mozilla.firefox.desktop`,
2941 # which this image has never contained under any BROWSER value. It resolved
2942 # correctly anyway by falling through to mimeinfo.cache, where exactly one
2943 # candidate declared the scheme. A default that is wrong and works because
2944 # only one alternative exists is precisely what stops working the day the
2945 # browser becomes the user's choice.
2946 #
2947 # NoDisplay is asserted rather than assumed: without it alloy-open.desktop is
2948 # a browser candidate to alloy-open's own scan, and the script would exec
2949 # itself forever behind a keybind. The script excludes itself by filename too,
2950 # so this is the second of two guards rather than the only one.
2951 RUN set -eu; \
2952 test -x /usr/bin/alloy-open \
2953 || { echo "/etc/xdg/mimeapps.list points at alloy-open and it is not executable in the image" >&2; exit 1; }; \
2954 entry=/usr/share/applications/alloy-open.desktop; \
2955 test -f "$entry" \
2956 || { echo "alloy-open has no desktop entry, so nothing can be registered against it" >&2; exit 1; }; \
2957 grep -q '^NoDisplay=true' "$entry" \
2958 || { echo "alloy-open.desktop lacks NoDisplay=true; it would advertise itself as a browser to its own scan and to alloy-menu" >&2; exit 1; }; \
2959 for t in x-scheme-handler/http x-scheme-handler/https text/html; do \
2960 named="$(sed -n "s|^$t=||p" /etc/xdg/mimeapps.list | head -n 1)"; \
2961 [ -n "$named" ] \
2962 || { echo "/etc/xdg/mimeapps.list has no default for $t" >&2; exit 1; }; \
2963 test -f "/usr/share/applications/$named" \
2964 || { echo "/etc/xdg/mimeapps.list points $t at $named, which is not in the image" >&2; exit 1; }; \
2965 done; \
2966 echo "web handler: $(sed -n 's|^x-scheme-handler/https=||p' /etc/xdg/mimeapps.list | head -n 1), registered for http, https and text/html"
2967
2968 # =====================================================================
2969 # The mesh sign-in helper — assert the client, the unit and the script agree.
2970 #
2971 # crates/alloy/src/mesh.rs runs `run0 alloy-mesh-up` rather than
2972 # `run0 tailscale up`, because the preset below ships tailscaled disabled and a
2973 # sign-in that starts no daemon spends the user's password before it fails. All
2974 # three pieces have to be in the image for that to hold, and each one goes
2975 # missing in a different way: the script by a COPY that stopped matching, the
2976 # unit by a tailscale package that stopped shipping it, the client by the
2977 # package being dropped from the client profile.
2978 #
2979 # Asserted here rather than trusted because the failure is invisible at build
2980 # time and lands on a first boot, which is the one run where a user has no other
2981 # way in and no reason to suspect the image.
2982 RUN set -eu; \
2983 test -x /usr/bin/alloy-mesh-up \
2984 || { echo "mesh.rs runs alloy-mesh-up under run0 and it is not executable in the image" >&2; exit 1; }; \
2985 command -v tailscale >/dev/null \
2986 || { echo "alloy-mesh-up execs tailscale and the client is not in the image" >&2; exit 1; }; \
2987 unit=/usr/lib/systemd/system/tailscaled.service; \
2988 test -f "$unit" \
2989 || { echo "alloy-mesh-up enables tailscaled.service and $unit is not in the image" >&2; exit 1; }; \
2990 grep -q 'systemctl enable --now tailscaled.service' /usr/bin/alloy-mesh-up \
2991 || { echo "alloy-mesh-up no longer enables tailscaled; the sign-in would run against a stopped daemon" >&2; exit 1; }; \
2992 echo "mesh: alloy-mesh-up enables tailscaled.service, then signs in with $(tailscale version | head -n 1)"
2993
2994 # =====================================================================
2995 # The installer's ssh door — assert both halves of the gate are intact.
2996 # =====================================================================
2997 # `Match User installer` + `ForceCommand /usr/bin/alloy install` is a
2998 # remote path to a program that writes disks. What keeps it off an
2999 # installed machine is that the account does not exist there, and the
3000 # account is created by alloy-installer-ssh.service under the same
3001 # `alloy.installer` kernel flag alloy-installer.service uses. Neither
3002 # file can state that on its own, so the agreement between them is
3003 # checked here — the failure would otherwise be silent and remote.
3004 #
3005 # Three things, each of which has a way of going wrong quietly.
3006 #
3007 # 1. THE CONDITION. A unit that lost it would create the account on every
3008 # install, which is the whole hazard rather than a degradation of it.
3009 #
3010 # 2. THE NAME. The sshd Match and the useradd have to agree, and nothing
3011 # links them but the string. Disagreeing fails in the safe direction
3012 # (nobody logs in) but breaks the flow with no diagnostic anywhere.
3013 #
3014 # 3. THE SCOPE TERMINATOR. sshd applies a Match to everything that
3015 # follows it, and Include does not end that scope, so a drop-in whose
3016 # Match is left open swallows the global policy of every file after
3017 # it, this image's own `PasswordAuthentication no` included. The
3018 # symptom is not a parse error; it is a machine whose ssh policy has
3019 # quietly stopped applying to anyone but the installer. Two `Match`
3020 # lines and `Match all` last is the shape that cannot do that.
3021 #
3022 # 4. THE PRIVILEGE. `alloy install` does not escalate, so the wizard runs
3023 # under run0 and run0 asks polkit for manage-units. Three ways for
3024 # that to break quietly, and the door is remote in all of them: the
3025 # ForceCommand loses run0 and the wizard fails on the first
3026 # privileged command (measured 2026-08-26, GO alloy 2cf04f20); the
3027 # rules file names a different account than the one that logs in, and
3028 # run0 asks a human who is not there; or the grant widens past the
3029 # one action run0 needs. The first two look identical from outside —
3030 # an install that stops — and the third looks like nothing at all.
3031 #
3032 # Comments stay out of the RUN below: a `#` line inside a line
3033 # continuation is handled differently by different parsers, and one that
3034 # reaches the shell ends the command at that point rather than being
3035 # ignored, which would silently skip every check under it.
3036 RUN set -eu; \
3037 conf=/etc/ssh/sshd_config.d/20-alloy-installer.conf; \
3038 unit=/etc/systemd/system/alloy-installer-ssh.service; \
3039 grep -q '^ConditionKernelCommandLine=alloy.installer$' "$unit" \
3040 || { echo "$unit does not gate on alloy.installer, so the installer account would exist on installed machines" >&2; exit 1; }; \
3041 grep -q '^Match User installer$' "$conf" \
3042 || { echo "$conf no longer matches the account $unit creates" >&2; exit 1; }; \
3043 grep -q 'useradd .*installer' "$unit" \
3044 || { echo "$unit no longer creates the account $conf matches" >&2; exit 1; }; \
3045 [ "$(grep -c '^Match ' "$conf")" = 2 ] \
3046 || { echo "$conf must hold exactly two Match lines: the block and its terminator" >&2; exit 1; }; \
3047 tail -n 1 "$conf" | grep -q '^Match all$' \
3048 || { echo "$conf must end with 'Match all' or its block leaks into every later sshd drop-in" >&2; exit 1; }; \
3049 rules=/usr/share/polkit-1/rules.d/50-alloy-installer.rules; \
3050 grep -q '^ForceCommand run0 .*/usr/bin/alloy install$' "$conf" \
3051 || { echo "$conf does not run the installer under run0; the wizard would land unprivileged and fail on the first command that writes a disk" >&2; exit 1; }; \
3052 test -f "$rules" \
3053 || { echo "$rules is missing, so run0 would ask a human for a password on a headless install" >&2; exit 1; }; \
3054 grep -q '"installer"' "$rules" \
3055 || { echo "$rules does not name the account $unit creates, so the grant reaches nobody" >&2; exit 1; }; \
3056 grep -q '"org.freedesktop.systemd1.manage-units"' "$rules" \
3057 || { echo "$rules does not grant manage-units, which is the action run0 asks for" >&2; exit 1; }; \
3058 [ "$(grep -c 'polkit.Result' "$rules")" = 1 ] \
3059 || { echo "$rules returns more than one polkit result; this grant is one action for one user and widening it is a decision, not an edit" >&2; exit 1; }; \
3060 echo "installer ssh: gated on alloy.installer, scope closed, run0 granted manage-units"
3061
3062 # =====================================================================
3063 # The installer's mDNS unit, gated by the same flag as the two above.
3064 #
3065 # The headless flow is `ssh installer@<hostname>.local`, and the server
3066 # profile's firewall zone does not allow mDNS on purpose, because that
3067 # profile also runs on a public address. The live medium is where that
3068 # reasoning inverts: it is on a LAN, it has joined no mesh, and it exists
3069 # for the length of an install.
3070 #
3071 # Three ways this unit could ship and do nothing, all silent, and each is
3072 # checked: the wrong gate (so it would run on installed machines, which is
3073 # the direction that matters), `--permanent` (which would write the opening
3074 # into /etc and hand it to the machine being installed), and an ordering
3075 # that lets it run before firewalld is up, where firewall-cmd fails and the
3076 # medium is unreachable by name for reasons nobody can see from outside.
3077 # =====================================================================
3078 RUN set -eu; \
3079 unit=/etc/systemd/system/alloy-installer-firewall.service; \
3080 test -f "$unit" \
3081 || { echo "$unit did not land; a server-profile medium would boot undiscoverable" >&2; exit 1; }; \
3082 grep -q '^ConditionKernelCommandLine=alloy.installer$' "$unit" \
3083 || { echo "$unit does not gate on alloy.installer, so installed machines would open mDNS too" >&2; exit 1; }; \
3084 ! grep '^ExecStart=' "$unit" | grep -q -- '--permanent' \
3085 || { echo "$unit uses --permanent; the opening would survive into the installed system" >&2; exit 1; }; \
3086 grep -q '^After=firewalld.service$' "$unit" \
3087 || { echo "$unit is not ordered after firewalld; firewall-cmd would fail with no daemon" >&2; exit 1; }; \
3088 echo "installer mdns: gated on alloy.installer, runtime only"
3089
3090 # =====================================================================
3091 # The encrypted install path — assert bootc will actually permit it.
3092 #
3093 # `bootc install --block-setup tpm2-luks` is gated by the image's own
3094 # install config, not by the hardware: with no `block` key bootc allows
3095 # `direct` alone and refuses the flag with "tpm2-luks not enabled in
3096 # installation config". That is exactly what happened, on a machine with
3097 # a working TPM, because the config declared a root filesystem and
3098 # nothing else. The installer offers encryption on step 4 and defaults
3099 # it to on, so every user taking the default hit a dead path.
3100 #
3101 # Nothing failed at build time, which is why this check exists: the flag
3102 # lives in crates/alloy/src/install.rs and the permission lives in a
3103 # TOML file, and neither knows the other exists. The coupling is only
3104 # observable at the moment a disk is being erased.
3105 #
3106 # The order check is not pedantry. bootc uses the FIRST entry as the
3107 # default for an install that passes no --block-setup, and the installer
3108 # omits the flag precisely when the user declined encryption. Put
3109 # tpm2-luks first and declining encryption would hand back an encrypted
3110 # disk whose passphrase nobody was asked for.
3111 # =====================================================================
3112 RUN set -eu; \
3113 conf=/usr/lib/bootc/install/00-alloy.toml; \
3114 test -f "$conf" \
3115 || { echo "$conf is missing; bootc would lose both the rootfs type and the block allowlist" >&2; exit 1; }; \
3116 block="$(sed -n 's/^[[:space:]]*block[[:space:]]*=[[:space:]]*//p' "$conf")"; \
3117 test -n "$block" \
3118 || { echo "$conf declares no 'block' key, so bootc permits 'direct' only and the installer's encryption step cannot work" >&2; exit 1; }; \
3119 echo "$block" | grep -q 'tpm2-luks' \
3120 || { echo "$conf does not enable tpm2-luks: $block" >&2; exit 1; }; \
3121 echo "$block" | grep -q '^\[[[:space:]]*"direct"' \
3122 || { echo "$conf must list \"direct\" first; bootc takes the first entry as the default and an unencrypted install passes no --block-setup: $block" >&2; exit 1; }; \
3123 echo "bootc install config: block = $block"
3124
3125 # =====================================================================
3126 # Fingerprint unlock — turn it on through authselect, not by hand.
3127 #
3128 # /etc/pam.d/system-auth is generated: on this base /etc/nsswitch.conf is
3129 # already a symlink into authselect's tree, and hand-editing a PAM file
3130 # authselect owns means the next `authselect apply-changes` silently
3131 # discards it. The feature switch is the supported edit and the only one
3132 # that survives.
3133 #
3134 # `sufficient` is what the feature installs, which is the property worth
3135 # stating: a finger that does not match, a reader that is busy, or a user
3136 # with nothing enrolled all fall through to the password prompt rather
3137 # than locking anyone out. That matters here more than usual, because
3138 # nothing enrolls a finger at install time — `fprintd-enroll` is a thing
3139 # the user runs later, and until they do this changes nothing at all.
3140 #
3141 # Asserted rather than trusted, twice. authselect reports success for a
3142 # feature it did not apply if the profile does not offer it, and the
3143 # generated file is the only place the answer is visible.
3144 #
3145 # authselect prints "make sure fprintd service is configured and enabled"
3146 # here. Nothing to do: fprintd is Type=dbus with BusName and a
3147 # system-services activation file, so the first PAM call starts it. No
3148 # preset line, for the same reason gnome-keyring does not get one.
3149 # =====================================================================
3150 #
3151 # Client-only. A fingerprint reader is a laptop part, the two consumers named
3152 # above are the lock screen and the greeter, and neither exists on `server`.
3153 # A headless machine authenticates with a key.
3154 RUN set -eu; \
3155 if [ "$PROFILE" = client ]; then \
3156 authselect enable-feature with-fingerprint; \
3157 authselect apply-changes; \
3158 grep -q 'pam_fprintd\.so' /etc/pam.d/system-auth \
3159 || { echo "with-fingerprint did not reach system-auth; the lock screen would never ask the reader" >&2; exit 1; }; \
3160 grep -q '^auth.*sufficient.*pam_fprintd\.so' /etc/pam.d/system-auth \
3161 || { echo "pam_fprintd is in system-auth but not as 'sufficient'; a failed or absent finger would not fall through to the password" >&2; exit 1; }; \
3162 test -f /etc/pam.d/swaylock \
3163 || { echo "swaylock ships no PAM file; the include chain this relies on is gone" >&2; exit 1; }; \
3164 echo "fingerprint: $(grep -c pam_fprintd /etc/pam.d/system-auth) pam_fprintd line(s) in system-auth"; \
3165 else \
3166 grep -q 'pam_fprintd\.so' /etc/pam.d/system-auth \
3167 && { echo "profile=server but system-auth calls pam_fprintd; the feature was enabled anyway" >&2; exit 1; }; \
3168 echo "fingerprint: not on this profile"; \
3169 fi
3170
3171 # =====================================================================
3172 # The session wrapper — assert it can actually run.
3173 #
3174 # etc/greetd/config.toml points --cmd at alloy-session rather than at
3175 # sway, which puts this one script on the boot path: missing,
3176 # non-executable, or carrying a bad shebang, and nobody can log into the
3177 # machine at all, with the virtual console as the only recovery. Both
3178 # failures are silent at build time and total at runtime, so they are
3179 # checked here for the same reason the polkit and setvtrgb steps check
3180 # theirs.
3181 #
3182 # `sh -n` parses without running, which is the whole check worth making:
3183 # the script's job is one alloy call and one exec, and a syntax error is
3184 # the only way that fails before it has a chance to matter.
3185 # =====================================================================
3186 #
3187 # The wrapper and the greetd config both arrive from the repo's `usr/` and
3188 # `etc/` trees, so on `server` they would be present and asserted correct
3189 # while nothing could ever run them. That is worse than absent: a machine
3190 # carrying a login path it cannot take. So `server` removes both and proves
3191 # they are gone, which is a check rather than a skip.
3192 RUN set -eu; \
3193 if [ "$PROFILE" = client ]; then \
3194 test -x /usr/bin/alloy-session \
3195 || { echo "alloy-session is missing or not executable; greetd --cmd would fail" >&2; exit 1; }; \
3196 sh -n /usr/bin/alloy-session \
3197 || { echo "alloy-session does not parse; no user could log in" >&2; exit 1; }; \
3198 grep -q -- '--cmd alloy-session' /etc/greetd/config.toml \
3199 || { echo "greetd does not launch the session wrapper; the skeleton would never be applied" >&2; exit 1; }; \
3200 else \
3201 rm -f /usr/bin/alloy-session; \
3202 rm -rf /etc/greetd; \
3203 test ! -e /usr/bin/alloy-session && test ! -e /etc/greetd \
3204 || { echo "profile=server still carries the session wrapper or greetd config" >&2; exit 1; }; \
3205 echo "session: none on this profile; boot lands on a getty"; \
3206 fi
3207
3208 # =====================================================================
3209 # The Secret Service — assert the provider is reachable and its unlock
3210 # is not inert.
3211 #
3212 # Three things have to hold together for an app to store a credential
3213 # without prompting, and every one of them fails quietly on its own.
3214 #
3215 # 1. Something has to claim org.freedesktop.secrets. That is a D-Bus
3216 # activation file, not a running daemon: the bus starts
3217 # gnome-keyring-daemon on the first call. Absent, the `keyring`
3218 # crate returns an error from inside the app, which is where this
3219 # whole task started.
3220 # 2. pam_gnome_keyring.so has to be loadable, or the `-` prefixed
3221 # stanzas in greetd's PAM stack are skipped in silence and the
3222 # keyring stays locked behind a second prompt. The module is in
3223 # gnome-keyring-pam, a separate package; see the install group.
3224 # 3. greetd's PAM stack has to still carry those stanzas. They are
3225 # Fedora's, not Alloy's, so nothing in this repo would notice them
3226 # leaving. Alloy ships no /etc/pam.d file, which is deliberate —
3227 # copying greetd's stack in to add two lines it already has means
3228 # owning a file that upstream keeps changing — and the cost of not
3229 # owning it is exactly this check.
3230 #
3231 # The `auto_start` on the session line is named rather than matched
3232 # loosely: without it the module authenticates against the keyring and
3233 # never starts the daemon, which reads as the unlock working right up
3234 # until an app asks for a secret.
3235 # =====================================================================
3236 #
3237 # Client-only, and note the whole check hangs off greetd's PAM file, which
3238 # `server` deleted above. There is no session bus on a headless box to hold
3239 # the provider on, and gopass — which is base — is the answer there instead.
3240 # The `else` proves the provider really did not arrive, since gnome-keyring
3241 # is exactly the kind of package that turns up as a weak dependency.
3242 RUN set -eu; \
3243 service=/usr/share/dbus-1/services/org.freedesktop.secrets.service; \
3244 if [ "$PROFILE" = client ]; then \
3245 set -x; \
3246 [ -f "$service" ] \
3247 || { echo "nothing claims org.freedesktop.secrets; every app using the keyring crate fails at runtime" >&2; exit 1; }; \
3248 grep -q '^Name=org.freedesktop.secrets$' "$service" \
3249 || { echo "$service no longer claims the org.freedesktop.secrets name" >&2; exit 1; }; \
3250 module=$(find /usr/lib64/security /usr/lib/security -name pam_gnome_keyring.so 2>/dev/null | head -n 1); \
3251 [ -n "$module" ] \
3252 || { echo "pam_gnome_keyring.so is absent; greetd's '-' prefixed stanzas would be skipped silently and the keyring would prompt separately" >&2; exit 1; }; \
3253 grep -q '^-*auth .*pam_gnome_keyring\.so' /etc/pam.d/greetd \
3254 || { echo "greetd's PAM stack no longer unlocks the keyring on auth; Alloy would have to ship its own /etc/pam.d/greetd" >&2; exit 1; }; \
3255 grep -q '^-*session .*pam_gnome_keyring\.so.*auto_start' /etc/pam.d/greetd \
3256 || { echo "greetd's PAM stack no longer starts the keyring daemon at session open; secrets would fail after a login that looked fine" >&2; exit 1; }; \
3257 echo "secret service: provider present, $module loadable, greetd unlocks it"; \
3258 else \
3259 [ ! -f "$service" ] \
3260 || { echo "profile=server but something claims org.freedesktop.secrets; gnome-keyring got installed" >&2; exit 1; }; \
3261 echo "secret service: none on this profile, and no gopass either since 2026-08-17"; \
3262 fi
3263
3264 # gopass, and the one thing about it that can be wrong without saying so.
3265 #
3266 # The store is age-backed rather than GPG-backed (docs/STACK.md#secrets),
3267 # and age support is compiled into gopass rather than shipped as a plugin,
3268 # so a Fedora build with the feature dropped would still install, still
3269 # run, and only fail when a store is initialized. `gopass age --help` is
3270 # the cheapest question that distinguishes the two.
3271 #
3272 # HOME is redirected at a temporary directory and the directory removed,
3273 # which is not tidiness. gopass writes a config file on any invocation,
3274 # including one that only asks for help, so the plain form of this check
3275 # bakes /root/.config/gopass/config into the shipped image: a config for
3276 # an account with no store, authored by a build step, sitting where the
3277 # real one would go the first time anyone runs gopass as root. A guard
3278 # that leaves state behind is a guard that changes what it was checking.
3279 # CONDITIONAL SINCE 2026-08-17, when gopass moved to the client block. Both
3280 # of these ran unconditionally and both failed the first server build after
3281 # the move, which is the third instance of the shape subtask 825465e0 named:
3282 # not a conditional with a silent half, but an assertion with no conditional
3283 # at all, written when every profile carried the thing it checks. Kept as two
3284 # RUNs rather than merged, because the second is the expensive one and the
3285 # first is what makes its failure legible.
3286 RUN if [ "$PROFILE" = client ]; then \
3287 command -v gopass >/dev/null \
3288 || { echo "gopass is missing; docs/STACK.md names it as the password manager" >&2; exit 1; }; \
3289 else \
3290 ! command -v gopass >/dev/null \
3291 || { echo "profile=server but gopass is installed; it moved to the client block and takes fish with it" >&2; exit 1; }; \
3292 ! command -v fish >/dev/null \
3293 || { echo "profile=server but fish is installed; nothing here runs it and only gopass wanted it" >&2; exit 1; }; \
3294 echo "password store: none on this profile, and no fish behind it"; \
3295 fi
3296 RUN if [ "$PROFILE" = client ]; then \
3297 set -eux; \
3298 probe=$(mktemp -d); \
3299 HOME="$probe" gopass age --help >/dev/null 2>&1 \
3300 || { rm -rf "$probe"; echo "this gopass build has no age backend; the whole reason it was picked over pass is gone" >&2; exit 1; }; \
3301 rm -rf "$probe"; \
3302 [ ! -e /root/.config/gopass ] \
3303 || { echo "the gopass probe wrote into /root anyway; it would ship in the image" >&2; exit 1; }; \
3304 else \
3305 [ ! -e /root/.config/gopass ] \
3306 || { echo "profile=server has a gopass config; something ran it during the build" >&2; exit 1; }; \
3307 echo "no gopass on this profile, so no age-backend probe and nothing to leave behind"; \
3308 fi
3309
3310 # =====================================================================
3311 # The screenshot helper — assert the binds reach it.
3312 #
3313 # Not the boot path, so a lighter case than alloy-session above, but the
3314 # same silence: the four Print binds `exec alloy-shot <mode>`, and sway's
3315 # exec reports a command it cannot run to its own log and nowhere the
3316 # person pressing the key will see. Missing, non-executable, or a mode
3317 # named in the config that the script does not answer to, and the keys go
3318 # back to doing nothing, which is the state this whole helper exists to
3319 # end.
3320 #
3321 # The modes are read back out of the config's binds and asked of the
3322 # script, so the two cannot drift: a renamed mode fails here rather than
3323 # on a keypress. Every dependency the script shells out to is checked in
3324 # the same pass, since each one is a bind that silently stops working.
3325 # =====================================================================
3326 #
3327 # Client-only: six of the tools it needs are client packages and the modes
3328 # are read out of the sway config. Removed on `server` for the same reason
3329 # as alloy-session and alloy-dim — a Print-screen helper on a machine with
3330 # no screen is a script that can only fail.
3331 RUN set -eu; \
3332 if [ "$PROFILE" = client ]; then \
3333 set -x; \
3334 test -x /usr/bin/alloy-shot \
3335 || { echo "alloy-shot is missing or not executable; every Print bind would do nothing" >&2; exit 1; }; \
3336 sh -n /usr/bin/alloy-shot \
3337 || { echo "alloy-shot does not parse; every Print bind would do nothing" >&2; exit 1; }; \
3338 for tool in grim slurp jq swaymsg satty notify-send; do \
3339 command -v "$tool" >/dev/null \
3340 || { echo "alloy-shot needs $tool and it is not in the image" >&2; exit 1; }; \
3341 done; \
3342 modes=$(sed -n 's/^bindsym [^ ]*Print *exec alloy-shot \([a-z]*\).*/\1/p' /etc/skel/.config/sway/config); \
3343 [ -n "$modes" ] \
3344 || { echo "no Print bind in the shipped sway config calls alloy-shot" >&2; exit 1; }; \
3345 for mode in $modes; do \
3346 grep -q "^ $mode)" /usr/bin/alloy-shot \
3347 || { echo "the sway config binds alloy-shot $mode, which the script does not handle" >&2; exit 1; }; \
3348 done; \
3349 echo "alloy-shot: $(echo "$modes" | wc -l) bound modes, all handled"; \
3350 else \
3351 rm -f /usr/bin/alloy-shot /usr/bin/alloy-menu /usr/bin/alloy-clipmenu \
3352 /usr/bin/alloy-clipstore /usr/bin/alloy-secret-copy /usr/bin/alloy-secret-clear; \
3353 test ! -e /usr/bin/alloy-shot \
3354 || { echo "profile=server still carries alloy-shot" >&2; exit 1; }; \
3355 test ! -e /usr/bin/alloy-clipstore \
3356 || { echo "profile=server still carries alloy-clipstore; there is no clipboard on a headless box" >&2; exit 1; }; \
3357 fi
3358
3359 # =====================================================================
3360 # The private clipboard — assert the leak stays closed.
3361 #
3362 # Alloy ships a password manager and a clipboard history, and until
3363 # 2026-08-17 the second recorded the output of the first: `gopass show -c`
3364 # put a password on the clipboard and the sway config's watchers wrote it
3365 # into cliphist's database, where $mod+Shift+v read it back. GO alloy
3366 # problem e7a9e38c.
3367 #
3368 # Three parts have to agree or the leak is silently back, and no part
3369 # reports its own absence:
3370 #
3371 # 1. the scripts exist and parse
3372 # 2. the sway config's watchers call alloy-clipstore, not `cliphist store`
3373 # 3. gopass is pointed at alloy-secret-copy through environment.d
3374 #
3375 # Miss (2) and the watcher stores everything. Miss (3) and gopass finds
3376 # wl-copy by itself. Both look exactly like a working system: the password
3377 # is on the clipboard, the paste works, and the only difference is a row in
3378 # a database nobody opens. That is the same shape as the emoji alias and the
3379 # swayosd unit path, which is why it is asserted rather than trusted.
3380 RUN set -eu; \
3381 if [ "$PROFILE" = client ]; then \
3382 set -x; \
3383 for script in alloy-clipstore alloy-secret-copy alloy-secret-clear; do \
3384 test -x "/usr/bin/$script" \
3385 || { echo "$script is missing or not executable; every copied password would be archived" >&2; exit 1; }; \
3386 sh -n "/usr/bin/$script" \
3387 || { echo "$script does not parse; the private clipboard would fail open" >&2; exit 1; }; \
3388 done; \
3389 watchers=$(grep -c '^exec wl-paste .*--watch alloy-clipstore' /etc/skel/.config/sway/config); \
3390 [ "$watchers" = 2 ] \
3391 || { echo "expected 2 clipboard watchers through alloy-clipstore, found $watchers; a password would reach cliphist" >&2; exit 1; }; \
3392 ! grep -q '^exec wl-paste .*--watch cliphist store' /etc/skel/.config/sway/config \
3393 || { echo "a watcher still calls cliphist store directly, so it records secrets too" >&2; exit 1; }; \
3394 env=/etc/skel/.config/environment.d/alloy.conf; \
3395 grep -q '^GOPASS_CLIPBOARD_COPY_CMD=/usr/bin/alloy-secret-copy$' "$env" \
3396 || { echo "gopass is not pointed at alloy-secret-copy; it would call wl-copy itself and the leak is back" >&2; exit 1; }; \
3397 grep -q '^GOPASS_CLIPBOARD_CLEAR_CMD=/usr/bin/alloy-secret-clear$' "$env" \
3398 || { echo "gopass has no clear command, so clipboard history would stay paused until the backstop" >&2; exit 1; }; \
3399 echo "private clipboard: 2 watchers gated, gopass routed through it"; \
3400 else \
3401 for script in alloy-clipstore alloy-secret-copy alloy-secret-clear; do \
3402 test ! -e "/usr/bin/$script" \
3403 || { echo "profile=server still carries $script; there is no clipboard and no gopass on this profile" >&2; exit 1; }; \
3404 done; \
3405 echo "private clipboard: nothing to gate on a headless profile"; \
3406 fi
3407
3408 # =====================================================================
3409 # alloy-backdrop — the desktop background, and the reference it is.
3410 #
3411 # An installed machine ships no keybinding reference at all: the only copy is
3412 # docs/manual/05-keybindings.md and no COPY puts docs/ into the image. This
3413 # panel is it. So a build where the script is absent, unparseable, or simply
3414 # not started leaves a machine whose keys are documented nowhere the person
3415 # using it can reach.
3416 #
3417 # Two halves, and each is silent on its own. A missing script means shop exits
3418 # immediately and the desktop falls back to the flat colour, which looks like a
3419 # deliberate plain background rather than a failure. A missing exec line means
3420 # the script is present, correct and never run.
3421 #
3422 # The flat colour is asserted too. It is the fallback for a compositor with no
3423 # wlr-layer-shell, and losing it would turn that case from a plain background
3424 # into no background at all.
3425 #
3426 # THE BIND COUNT IS THE ONE CHECKED HERE, and the verbs deliberately are not.
3427 # The console is not in /usr/bin at build time: it travels as an uninstalled
3428 # RPM and alloy-layer-components.service lays it down on the first boot (see
3429 # "The component repo" above). So `alloy --help` answers nothing here, which is
3430 # also true of the first boot before layering finishes. Running the panel under
3431 # exactly that condition is the point: it must still draw every key, because a
3432 # backdrop that needed the console would be blank on the one boot where a
3433 # person most needs to know which keys exist. The verb half is covered by
3434 # crates/alloy/tests/backdrop.rs against a stub.
3435 RUN set -eu; \
3436 if [ "$PROFILE" = client ]; then \
3437 test -x /usr/bin/alloy-backdrop \
3438 || { echo "alloy-backdrop is missing or not executable; the desktop would be a flat colour and the keybindings documented nowhere on the machine" >&2; exit 1; }; \
3439 sh -n /usr/bin/alloy-backdrop \
3440 || { echo "alloy-backdrop does not parse; shop would exit at once and the background would silently fall back" >&2; exit 1; }; \
3441 test -x /usr/bin/alloy-drift \
3442 || { echo "alloy-drift is missing or not executable; nothing would run the panel and the desktop would be a flat colour" >&2; exit 1; }; \
3443 /usr/bin/alloy-drift --help | grep -q -- '--pattern' \
3444 || { echo "alloy-drift does not answer --help; the loop that draws the backdrop is not the binary that was built" >&2; exit 1; }; \
3445 conf=/etc/skel/.config/sway/config; \
3446 grep -q '^exec \$term --layer background -e /usr/bin/alloy-drift$' "$conf" \
3447 || { echo "the session does not start alloy-drift; the backdrop would ship and never run" >&2; exit 1; }; \
3448 grep -q '^bindsym .* exec pkill -USR1 -x alloy-drift$' "$conf" \
3449 || { echo "no bind toggles the backdrop; the panel could never be put away and, because the key list is derived from these binds, nothing on the machine would say so" >&2; exit 1; }; \
3450 grep -q '^output \* bg .* solid_color$' "$conf" \
3451 || { echo "the solid colour fallback is gone; a compositor without wlr-layer-shell would have no background at all" >&2; exit 1; }; \
3452 binds=$(grep -c '^[[:space:]]*bindsym[[:space:]]' "$conf"); \
3453 drawn=$(ALLOY_BACKDROP_CONFIG="$conf" ALLOY_BACKDROP_SIZE="500 1" \
3454 /usr/bin/alloy-backdrop --once \
3455 | sed -e 's/\x1b\[[0-9;]*m//g' \
3456 | awk '/^ keys$/{k=1;next} k && NF {n++} END{print n+0}'); \
3457 [ "$binds" = "$drawn" ] \
3458 || { echo "the config has $binds binds and the panel draws $drawn; a bind the parser does not recognise is missing from the only reference the image ships" >&2; exit 1; }; \
3459 echo "backdrop: $drawn of $binds binds drawn with no console present"; \
3460 else \
3461 rm -f /usr/bin/alloy-backdrop /usr/bin/alloy-drift; \
3462 test ! -e /usr/bin/alloy-backdrop \
3463 || { echo "profile=server still carries alloy-backdrop; there is no sway session to start it" >&2; exit 1; }; \
3464 test ! -e /usr/bin/alloy-drift \
3465 || { echo "profile=server still carries alloy-drift; there is no sway session to start it" >&2; exit 1; }; \
3466 echo "backdrop: none on this profile; there is no desktop to put one behind"; \
3467 fi
3468
3469 # =====================================================================
3470 # alloy-dim — the lock warning, and the two ways it goes quiet.
3471 #
3472 # swayidle calls this at 270 seconds and again on resume. Neither call is
3473 # visible: if the script is missing or unparseable, swayidle logs to its own
3474 # output and the observable result is the old behaviour, full brightness
3475 # straight to a lock, which is exactly the ambush the stage was added to
3476 # remove. A regression here restores a bug rather than causing a new one,
3477 # which is the kind that survives.
3478 #
3479 # The subcommands are read back out of the shipped sway config and asked of
3480 # the script, the same way alloy-shot's modes are, so a renamed one fails
3481 # here instead of on the idle chain.
3482 #
3483 # The `video` group is checked too, because it is the whole mechanism: the
3484 # script writes /sys/class/backlight/<dev>/brightness, which is writable only
3485 # through the udev rule copied above, and the installer puts the account in
3486 # `video` (crates/alloy/src/install.rs). A missing group makes the script exit
3487 # 0 on every call, having found no writable device — dimming would be absent
3488 # and silent, indistinguishable from it never having been added.
3489 # =====================================================================
3490 #
3491 # Client-only: the whole check reads the shipped sway config, which is the
3492 # thing `server` does not have. The script itself ships from the repo's
3493 # `usr/` tree, so as with alloy-session the `else` removes it rather than
3494 # leaving a dimmer on a machine with no backlight and no idle chain.
3495 RUN set -eu; \
3496 if [ "$PROFILE" = client ]; then \
3497 set -x; \
3498 test -x /usr/bin/alloy-dim \
3499 || { echo "alloy-dim is missing or not executable; the lock would arrive with no warning" >&2; exit 1; }; \
3500 sh -n /usr/bin/alloy-dim \
3501 || { echo "alloy-dim does not parse; the lock would arrive with no warning" >&2; exit 1; }; \
3502 verbs=$(sed -n "s/.*alloy-dim \([a-z]*\)'.*/\1/p" /etc/skel/.config/sway/config | sort -u); \
3503 [ -n "$verbs" ] \
3504 || { echo "the shipped sway config never calls alloy-dim; the warning stage is not wired up" >&2; exit 1; }; \
3505 for verb in $verbs; do \
3506 grep -q "^$verb)" /usr/bin/alloy-dim \
3507 || { echo "the sway config calls alloy-dim $verb, which the script does not handle" >&2; exit 1; }; \
3508 done; \
3509 getent group video >/dev/null \
3510 || { echo "no video group; alloy-dim would find no writable backlight and dim nothing" >&2; exit 1; }; \
3511 grep -q 'timeout 270' /etc/skel/.config/sway/config \
3512 || { echo "the warning stage is gone from the idle chain; alloy-dim would never be called" >&2; exit 1; }; \
3513 echo "alloy-dim: $(echo "$verbs" | wc -l) verbs wired, video group present"; \
3514 else \
3515 rm -f /usr/bin/alloy-dim; \
3516 test ! -e /usr/bin/alloy-dim \
3517 || { echo "profile=server still carries alloy-dim" >&2; exit 1; }; \
3518 fi
3519
3520 # =====================================================================
3521 # udisks — assert the daemon `alloy disk` drives is actually here.
3522 #
3523 # udisks2 arrives from the base rather than from a line in this file, and an
3524 # inherited dependency nothing states is the pattern that already went wrong
3525 # twice: bluez was enabled by Fedora's preset with no decision recorded, and the
3526 # emoji alias named a font the image never installed. `alloy disk` reads with
3527 # lsblk and acts with udisksctl, so if the base ever drops it the verb becomes a
3528 # read-only screen whose every action key refuses. That is a change worth failing
3529 # the build over rather than discovering on a plugged-in stick.
3530 #
3531 # Not `dnf install`: the base already carries it, and adding the line would claim
3532 # ownership of a dependency this file does not actually choose. The assertion is
3533 # the honest version.
3534 RUN set -eux; \
3535 command -v udisksctl >/dev/null \
3536 || { echo "no udisksctl; alloy disk could list volumes but never mount one" >&2; exit 1; }; \
3537 test -f /usr/lib/systemd/system/udisks2.service \
3538 || { echo "udisks2 ships no service unit; nothing would answer udisksctl" >&2; exit 1; }; \
3539 echo "udisks: present, D-Bus activated, drives alloy disk"
3540
3541 # polkit rules — assert the grant is not inert, and that the vendor
3542 # defaults it rests on are the ones it was written against.
3543 #
3544 # usr/share/polkit-1/rules.d/50-alloy-settings.rules turns five actions
3545 # into silent yeses so `alloy settings` does not raise a password prompt
3546 # to change a timezone. Three ways that file can be shipped and mean
3547 # something other than what it says, all silent:
3548 #
3549 # - polkit is not in the base image, so nothing reads rules.d
3550 # - an action id was renamed upstream, so the grant names something
3551 # that no longer exists and the console prompts anyway
3552 # - a vendor default moved: one that became `yes` makes the rule a
3553 # security artifact shipped for nothing, and one that became
3554 # `auth_admin` takes away the burst-caching the settings tab counts
3555 # on
3556 #
3557 # All three are checked here rather than discovered on a booted machine,
3558 # where the symptom is a password prompt nobody can explain. The action
3559 # ids are read back out of the shipped rule and the defaults out of the
3560 # image's own `.policy` files, so neither reading can drift from what it
3561 # is checked against. Grant rationale is in the rule's own header and in
3562 # wiki note `alloy-privilege`.
3563 #
3564 # The expected defaults are POLICY_TABLE in build/check-installed.sh,
3565 # bind-mounted for the length of this step rather than copied, so the
3566 # table has one home and the image gains no file for it. That script
3567 # asks the same question of a running machine with `--policy`.
3568 # =====================================================================
3569 RUN --mount=type=bind,source=build/check-installed.sh,target=/run/check-installed.sh \
3570 set -eux; \
3571 rules=/usr/share/polkit-1/rules.d/50-alloy-settings.rules; \
3572 [ -d /usr/share/polkit-1/actions ] \
3573 || { echo "no polkit in the base image; $rules would never be read" >&2; exit 1; }; \
3574 [ -f "$rules" ] || { echo "$rules did not land" >&2; exit 1; }; \
3575 granted=$(sed -n '/var granted = \[/,/]/p' "$rules" | grep -o '"[a-z0-9.-]*"' | tr -d '"'); \
3576 [ -n "$granted" ] || { echo "read no action ids out of $rules" >&2; exit 1; }; \
3577 for action in $granted; do \
3578 grep -qr "action id=\"$action\"" /usr/share/polkit-1/actions/ \
3579 || { echo "granted action $action is not one this image defines" >&2; exit 1; }; \
3580 done; \
3581 awk '/^POLICY_TABLE=/ { inside = 1; next } \
3582 inside && $0 !~ /^org\./ { inside = 0 } \
3583 inside && NF == 2 { print $1, $2 }' \
3584 /run/check-installed.sh > /tmp/policy-expected; \
3585 [ -s /tmp/policy-expected ] \
3586 || { echo "read no POLICY_TABLE out of build/check-installed.sh" >&2; exit 1; }; \
3587 awk 'match($0, /<action id="[^"]*"/) { id = substr($0, RSTART + 12, RLENGTH - 13) } \
3588 match($0, /<allow_active>[^<]*/) { print id, substr($0, RSTART + 14, RLENGTH - 14) }' \
3589 /usr/share/polkit-1/actions/*.policy > /tmp/policy-actual; \
3590 awk 'NR == FNR { actual[$1] = $2; next } \
3591 { rows++ } \
3592 !($1 in actual) { printf "%s is defined by no .policy file in this image\n", $1 > "/dev/stderr"; bad++; next } \
3593 actual[$1] != $2 { printf "%s reads %s, POLICY_TABLE says %s\n", $1, actual[$1], $2 > "/dev/stderr"; bad++ } \
3594 END { if (bad) { printf "%d polkit default(s) drifted from build/check-installed.sh; the ladder is built on that table, so correct it before the rules file\n", bad > "/dev/stderr"; exit 1 } \
3595 printf "polkit: %d implicit defaults match build/check-installed.sh\n", rows }' \
3596 /tmp/policy-actual /tmp/policy-expected; \
3597 rm -f /tmp/policy-expected /tmp/policy-actual; \
3598 echo "polkit: granted $(echo "$granted" | wc -l) actions, all defined"
3599
3600 # =====================================================================
3601 # pkttyagent — the console's answer when the grant above does not apply.
3602 #
3603 # The rule grants five actions to an *active local* session in wheel. An
3604 # ssh login is not that, which is correct and is the whole argument for
3605 # granting them at all, so the same settings rows prompt over ssh. The
3606 # console handles that by running the setter again with `pkttyagent`
3607 # beside it (crates/alloy/src/shell.rs, `Flow::Authorize`), which is
3608 # polkit's own text agent and needs no GTK or Qt.
3609 #
3610 # It ships in the polkit package, so the failure this checks for is not
3611 # "someone forgot to install it" but "the base split it out" — silent on
3612 # a built image, and discovered by a user over ssh whose timezone change
3613 # fails twice with the same message.
3614 # =====================================================================
3615 RUN set -eux; \
3616 command -v pkttyagent >/dev/null \
3617 || { echo "no pkttyagent; the console has no way to answer a polkit prompt" >&2; exit 1; }; \
3618 echo "pkttyagent: present"
3619
3620 # =====================================================================
3621 # polkit-agent-helper-1 — the same question for tier 3.
3622 #
3623 # The console registers an authentication agent of its own and draws the
3624 # prompt in Akari (crates/alloy/src/polkit.rs, `Flow::AuthorizeInline`).
3625 # What it does *not* do is run the PAM conversation: that is this setuid
3626 # helper's, deliberately, and it is the only reason an agent drawn by us
3627 # is not an authentication surface written by us.
3628 #
3629 # Checked for the same reason and against the same failure as pkttyagent
3630 # above — it ships inside the polkit package and a base that split it out
3631 # is silent at build time. Distinct from that check because it is a path
3632 # rather than a command on `$PATH`, and because the two are used by two
3633 # different tiers: losing this one leaves tier 2 working and takes away
3634 # the only way to join a wifi network, which needs a passphrase on stdin
3635 # that a suspended child has no pipe for.
3636 #
3637 # `-u` as well as `-x`: an agent helper that is not setuid root cannot
3638 # read the shadow file, so it would run, fail every password, and say
3639 # nothing about why.
3640 # =====================================================================
3641 RUN set -eux; \
3642 helper=; \
3643 for candidate in /usr/lib/polkit-1/polkit-agent-helper-1 \
3644 /usr/libexec/polkit-1/polkit-agent-helper-1; do \
3645 [ -x "$candidate" ] && helper="$candidate" && break; \
3646 done; \
3647 [ -n "$helper" ] \
3648 || { echo "no polkit-agent-helper-1; the console cannot answer polkit without leaving the TUI" >&2; exit 1; }; \
3649 [ -u "$helper" ] \
3650 || { echo "$helper is not setuid; every password it is given would fail" >&2; exit 1; }; \
3651 echo "polkit-agent-helper-1: $helper, setuid"
3652
3653 # =====================================================================
3654 # run0 — assert the way to root that Alloy documents is in the image.
3655 #
3656 # Alloy names `run0` and not `sudo` (docs/CONTINUITY.md,
3657 # docs/HARDWARE-FW12.md, the installer's account pane; rationale in wiki
3658 # note `alloy-privilege`). run0 is systemd's, arrived in 256, and is a
3659 # symlink to systemd-run: it asks PID 1 for a transient unit running as
3660 # root, and PID 1 asks polkit. So becoming root goes through the same
3661 # authority the console's writing views already go through, and a
3662 # fingerprint or FIDO2 factor configured once at polkit's PAM stack
3663 # covers both.
3664 #
3665 # sudo is still in the base and still works. Nothing here removes it;
3666 # what is asserted is only that the path the docs name is present, since
3667 # a manual telling a user to type run0 on an image without it is worse
3668 # than no manual. Three ways that can be wrong, all silent on a built
3669 # image:
3670 #
3671 # - the base dropped run0, so every doc naming it is a lie
3672 # - systemd is older than 256, so run0 predates its own existence
3673 # - the action a transient-unit request authenticates against is not
3674 # defined, so run0 has no admin to resolve and cannot authorize
3675 # =====================================================================
3676 RUN set -eux; \
3677 command -v run0 >/dev/null \
3678 || { echo "no run0 in the base image; the docs name it as the way to root" >&2; exit 1; }; \
3679 version=$(systemctl --version | sed -n '1s/^systemd \([0-9]*\).*/\1/p'); \
3680 [ -n "$version" ] || { echo "could not read a systemd version" >&2; exit 1; }; \
3681 [ "$version" -ge 256 ] \
3682 || { echo "systemd $version predates run0, which arrived in 256" >&2; exit 1; }; \
3683 action=org.freedesktop.systemd1.manage-units; \
3684 grep -qr "action id=\"$action\"" /usr/share/polkit-1/actions/ \
3685 || { echo "$action is not defined; run0 cannot ask polkit for an admin" >&2; exit 1; }; \
3686 echo "run0: present, systemd $version, $action defined"
3687
3688 # =====================================================================
3689 # Hardening, part 1: the SUID set.
3690 #
3691 # Filed out of the 2026-08-21 secureblue review; the adopt/reject list and
3692 # the reasoning are in wiki `alloy-hardening-posture`.
3693 #
3694 # A SUID binary is a program any user can run that becomes root, so each one
3695 # is a promise that its argument parsing, its environment handling and its
3696 # error paths are all correct. The base image ships sixteen. Six of them have
3697 # no caller on a machine with one human account and a console that owns
3698 # account management, and a promise nobody needs is one worth not making.
3699 #
3700 # WHAT IS NOT DONE HERE, deliberately: secureblue re-adds capabilities to
3701 # some of these so they keep working for unprivileged users. Kicksecure's
3702 # objection to that is sound — `cap_dac_read_search` on `unix_chkpwd` hands
3703 # out a dangerous capability to preserve a rarely-used convenience — so the
3704 # tools simply stop working without root, which is the cheaper answer.
3705 #
3706 # WHAT STAYS SUID, and why, because a list of removals without the
3707 # complement reads as an oversight:
3708 # - pkexec and polkit-agent-helper-1: the console's inline authorize flow
3709 # depends on the helper (crates/alloy/src/polkit.rs), and the block below
3710 # already asserts it is setuid.
3711 # - sudo and su: Alloy names run0 as the way to root (see above) and has
3712 # never removed sudo. Scripts across this tree call it. Removing it is a
3713 # separate decision with its own blast radius, not a line in a hardening
3714 # pass.
3715 # - passwd and unix_chkpwd: a user changing their own password, and the
3716 # lock screen checking it.
3717 # - mount, umount, mount.nfs, fusermount3: `alloy disk` and removable media.
3718 # - grub2-set-bootflag: the bootloader's one-shot flag.
3719 #
3720 # The result is asserted rather than assumed: chmod is silent about a file
3721 # that was already not setuid, and about one this list misspelled.
3722 # =====================================================================
3723 RUN set -eux; \
3724 stripped=""; \
3725 for bin in chfn chsh newgrp gpasswd chage pam_timestamp_check; do \
3726 path=$(command -v "$bin" 2>/dev/null || true); \
3727 [ -n "$path" ] || { echo "$bin is not in the image; this list names a binary the base no longer ships" >&2; exit 1; }; \
3728 [ -u "$path" ] || { echo "$path is already not setuid; the base changed and this line is now inert" >&2; exit 1; }; \
3729 chmod u-s "$path"; \
3730 [ -u "$path" ] && { echo "chmod u-s did not take on $path" >&2; exit 1; }; \
3731 stripped="$stripped $bin"; \
3732 done; \
3733 for keep in /usr/bin/sudo /usr/bin/passwd /usr/bin/mount /usr/bin/umount; do \
3734 [ -u "$keep" ] || { echo "$keep lost its setuid bit; the loop above is stripping more than it names" >&2; exit 1; }; \
3735 done; \
3736 echo "suid: stripped$stripped; $(find /usr/bin /usr/sbin /usr/libexec -perm -4000 -type f 2>/dev/null | wc -l) setuid binaries remain"
3737
3738 # =====================================================================
3739 # Hardening, part 2: faillock.
3740 #
3741 # etc/security/faillock.conf carries the numbers (50 attempts, 24 hours).
3742 # That file is inert on its own: what reads it is pam_faillock, and pam_faillock
3743 # is only in the stack if authselect's `with-faillock` feature is on. Same
3744 # shape as the fingerprint block above, and asserted the same way, because
3745 # "the file is present" and "the file is doing something" are different
3746 # claims and only the second one is worth making.
3747 #
3748 # Applies on both profiles. sshd takes keys only, so this is about the
3749 # console, the greeter and the lock screen — all of which exist on a server
3750 # too, in the form of whoever is standing at it.
3751 # =====================================================================
3752 RUN set -eux; \
3753 conf=/etc/security/faillock.conf; \
3754 [ -f "$conf" ] || { echo "$conf did not land from the config tree" >&2; exit 1; }; \
3755 grep -q '^deny = 50' "$conf" \
3756 || { echo "$conf does not set the deny count this image documents" >&2; exit 1; }; \
3757 authselect enable-feature with-faillock; \
3758 authselect apply-changes; \
3759 grep -q 'pam_faillock\.so' /etc/pam.d/system-auth \
3760 || { echo "with-faillock did not reach system-auth; $conf would never be read" >&2; exit 1; }; \
3761 grep -q 'pam_faillock\.so' /etc/pam.d/password-auth \
3762 || { echo "with-faillock reached system-auth but not password-auth; half the stack counts failures" >&2; exit 1; }; \
3763 echo "faillock: $(grep -c pam_faillock /etc/pam.d/system-auth) pam_faillock line(s) in system-auth, deny=50 unlock_time=86400"
3764
3765 # =====================================================================
3766 # Hardening, part 3: the clock.
3767 #
3768 # etc/chrony.d/10-alloy-nts.conf names four NTS servers. Two things have to
3769 # be true for it to matter, and neither is true of the file on its own:
3770 # /etc/chrony.conf has to read the directory, and it has to stop preferring
3771 # the unauthenticated pool it ships with.
3772 #
3773 # Both are edits to a file the chrony rpm owns. /etc is writable on a bootc
3774 # deployment so this is legal, and it is the same arrangement as the Firefox
3775 # pref file: the package's file, edited in place, with the edit stated here.
3776 # The pool line is commented rather than deleted so an operator reading the
3777 # file can see what was turned off and why.
3778 #
3779 # `chronyd -p` parses the configuration and exits, which is what makes this
3780 # an assertion instead of a hope. Without it, a typo in a server name would
3781 # be discovered by a machine that silently never synchronises.
3782 # =====================================================================
3783 RUN set -eux; \
3784 conf=/etc/chrony.conf; \
3785 drop=/etc/chrony.d/10-alloy-nts.conf; \
3786 [ -f "$drop" ] || { echo "$drop did not land from the config tree" >&2; exit 1; }; \
3787 grep -q '^pool ' "$conf" \
3788 || { echo "$conf has no pool line; the base changed and this edit no longer describes it" >&2; exit 1; }; \
3789 sed -i 's|^pool |# Commented by Alloy: unauthenticated NTP. See /etc/chrony.d/10-alloy-nts.conf.\n# pool |' "$conf"; \
3790 printf '\n# Added by Alloy: read /etc/chrony.d for the NTS sources.\nconfdir /etc/chrony.d\n' >> "$conf"; \
3791 ! grep -q '^pool ' "$conf" \
3792 || { echo "the pool line survived the edit; the machine would still take unauthenticated time" >&2; exit 1; }; \
3793 chronyd -p >/dev/null \
3794 || { echo "chronyd rejects the configuration after the Alloy edits" >&2; exit 1; }; \
3795 sources=$(chronyd -p 2>/dev/null | grep -c '^server .* nts$' || true); \
3796 [ "$sources" -ge 4 ] \
3797 || { echo "chronyd parsed the config and sees $sources NTS sources; the confdir is not being read" >&2; exit 1; }; \
3798 echo "chrony: pool commented, confdir /etc/chrony.d, $sources NTS sources"
3799
3800 # =====================================================================
3801 # Hardening, part 4: assert the drop-in files are not inert.
3802 #
3803 # Six files landed from the config tree with nothing else to turn them on.
3804 # Each one has a way of being present and doing nothing, and every one of
3805 # those ways is silent on a built image:
3806 #
3807 # - a sysctl file that loses to a higher-numbered one, so its values never
3808 # take. The numbering is the whole defence and it is checked, not trusted.
3809 # - a modprobe file using `blacklist` where it means `install`, which stops
3810 # autoload and permits a direct modprobe.
3811 # - a kargs file whose new entries did not survive template rendering.
3812 # - a sway directive in a file the compositor does not include.
3813 # - a tmpfiles line naming a source that is not in the image, so the flatpak
3814 # overrides never reach the only path flatpak reads.
3815 # =====================================================================
3816 RUN set -eux; \
3817 hard=/usr/lib/sysctl.d/90-alloy-hardening.conf; \
3818 core=/usr/lib/sysctl.d/95-alloy-coredump.conf; \
3819 [ -f "$hard" ] && [ -f "$core" ] \
3820 || { echo "the sysctl drop-ins did not land" >&2; exit 1; }; \
3821 last=$(ls /usr/lib/sysctl.d/*.conf /etc/sysctl.d/*.conf 2>/dev/null \
3822 | xargs -n1 basename \
3823 | grep -v -e '^9[05]-alloy-' -e '^99-sysctl\.conf$' \
3824 | sort | tail -1); \
3825 [ "$(printf '%s\n%s\n' "$last" 90-alloy-hardening.conf | sort | tail -1)" = 90-alloy-hardening.conf ] \
3826 || { echo "$last sorts after the Alloy sysctl drop-ins and would win any key they share" >&2; exit 1; }; \
3827 grep -q '^kernel.core_pattern=|/bin/false' "$core" \
3828 || { echo "$core does not disable cores; an empty core_pattern means 'default name', not 'off'" >&2; exit 1; }; \
3829 mod=/usr/lib/modprobe.d/50-alloy-blacklist.conf; \
3830 [ -f "$mod" ] || { echo "$mod did not land" >&2; exit 1; }; \
3831 ! grep -q '^blacklist ' "$mod" \
3832 || { echo "$mod uses 'blacklist', which a direct modprobe ignores; it must use 'install <mod> /bin/false'" >&2; exit 1; }; \
3833 grep -q '^install squashfs' "$mod" \
3834 && { echo "$mod blacklists squashfs; the installer ISO boots a live root out of one and would stop booting" >&2; exit 1; }; \
3835 echo "modprobe: $(grep -c '^install ' "$mod") modules refused, squashfs left loadable for the ISO"; \
3836 kargs=/usr/lib/bootc/kargs.d/10-alloy.toml; \
3837 [ -f "$kargs" ] || { echo "$kargs did not land from the render stage" >&2; exit 1; }; \
3838 for karg in init_on_free=1 slab_nomerge page_alloc.shuffle=1 randomize_kstack_offset=on vsyscall=none debugfs=off; do \
3839 grep -q "\"$karg\"" "$kargs" \
3840 || { echo "$kargs is missing $karg; the render dropped it" >&2; exit 1; }; \
3841 done; \
3842 swayconf=/etc/sway/config.d/00-alloy.conf; \
3843 grep -q '^xwayland disable' "$swayconf" \
3844 || { echo "$swayconf does not disable Xwayland" >&2; exit 1; }; \
3845 tmpf=/usr/lib/tmpfiles.d/50-alloy-flatpak.conf; \
3846 src=/usr/share/alloy/flatpak/global; \
3847 [ -f "$tmpf" ] && [ -f "$src" ] \
3848 || { echo "the flatpak override or its tmpfiles rule did not land" >&2; exit 1; }; \
3849 grep -q "^C .*$src\$" "$tmpf" \
3850 || { echo "$tmpf does not copy $src; flatpak reads /var/lib/flatpak/overrides/global and nothing else" >&2; exit 1; }; \
3851 systemd-tmpfiles --dry-run --create "$tmpf" >/dev/null \
3852 || { echo "systemd-tmpfiles rejects $tmpf" >&2; exit 1; }; \
3853 dns=/etc/systemd/resolved.conf.d/10-alloy-dns.conf; \
3854 grep -q '^DNSOverTLS=opportunistic' "$dns" \
3855 || { echo "$dns does not set DNSOverTLS, or sets 'yes' before anyone measured it against MagicDNS" >&2; exit 1; }; \
3856 echo "hardening drop-ins: sysctl, modprobe, kargs, sway, flatpak, resolved — all present and non-inert"
3857
3858 # =====================================================================
3859 # Systemd presets — shipped via etc/systemd/{system,user}-preset/
3860 # in the config tree above. Split across system-preset (greetd,
3861 # tailscaled) and user-preset (swayosd, syncthing, gammastep).
3862 # See docs/CONTINUITY.md for rationale.
3863 #
3864 # The preset files declare the intended enable/disable state; they
3865 # do not by themselves create the wants/ symlinks. `systemctl
3866 # preset-all` reads every preset file and applies it — this must
3867 # run after the config tree is in place, so it lives here.
3868 # =====================================================================
3869
3870 # The SwayOSD unit is already on the search path by the time preset-all runs:
3871 # the copy that puts it there is up with the rest of the packaging shims, next
3872 # to the udev rule that has the same defect. A second block used to stand here
3873 # doing the same job with a symlink, guarded by `if [ -e "$canon" ]`. Because
3874 # the copy had already created that path, the guard always took its first
3875 # branch and printed "the shim is already in the search path" — which read
3876 # like the workaround having become unnecessary, when in fact the earlier copy
3877 # was what satisfied it. Two fixes for one bug, landed the same day, and the
3878 # survivor is the one that fails the build loudly when Fedora corrects the
3879 # packaging rather than the one that quietly steps aside.
3880 # =====================================================================
3881 # The server profile's prune, and its preset overlay.
3882 # =====================================================================
3883 # Everything above this point has run, including every client-only
3884 # assertion, so this is the last safe place to remove the desktop
3885 # skeleton: the alloy-shot and alloy-dim checks read the shipped sway
3886 # config, and pruning it earlier would have made them unverifiable rather
3887 # than merely inapplicable. Order is the whole reason this is one block at
3888 # the end instead of a branch inside the COPY layers.
3889 #
3890 # What survives the prune is deliberate. `alloy-vtrgb` and its two tables
3891 # stay, and they matter more here than on a client: the bare console
3892 # palette is the only themed surface a headless machine has, so it stops
3893 # being first-impression polish and becomes the whole of the design
3894 # system on that box (wiki `alloy-server-variant`).
3895 #
3896 # The preset overlay is a separate file rather than an edit to
3897 # 50-alloy.preset, and it is numbered 40 so it sorts first — systemd
3898 # takes the first matching line, so a `disable` here beats the `enable`
3899 # below it without either file having to know about the other. Two units
3900 # need it. greetd and swayosd would match nothing on this profile, which
3901 # is the silent no-op this repo documents elsewhere; bluetooth is the
3902 # live one, because bluez can arrive from the base and Fedora's
3903 # 90-default.preset enables it, so without a line here a headless box
3904 # would run a Bluetooth daemon by inheritance. Max ruled both bluetooth
3905 # and cups off this profile on 2026-08-01.
3906 RUN set -eu; \
3907 if [ "$PROFILE" = server ]; then \
3908 rm -rf /etc/skel/.config/sway \
3909 /etc/skel/.config/shop \
3910 /etc/skel/.config/mako \
3911 /etc/skel/.config/swaylock \
3912 /etc/skel/.config/satty \
3913 /etc/skel/.config/swayosd \
3914 /etc/skel/.config/imv \
3915 /etc/skel/.config/mpv \
3916 /etc/skel/.config/zathura \
3917 /etc/skel/.config/gtk-3.0 \
3918 /etc/skel/.config/gtk-4.0 \
3919 /etc/skel/.config/fontconfig \
3920 /usr/share/alloy/skel-night; \
3921 test ! -e /etc/skel/.config/sway \
3922 || { echo "profile=server still carries the sway skeleton" >&2; exit 1; }; \
3923 test -s /usr/share/alloy/vtrgb \
3924 || { echo "the prune took the console palette with it; vtrgb is the one themed surface this profile has" >&2; exit 1; }; \
3925 mkdir -p /etc/systemd/system-preset; \
3926 { echo "# Alloy server profile: units the client enables that this profile must not."; \
3927 echo "# Sorts before 50-alloy.preset, and systemd takes the first match."; \
3928 echo "disable bluetooth.service"; \
3929 echo "disable cups.socket"; \
3930 echo "disable cups.path"; \
3931 echo "disable greetd.service"; \
3932 echo "disable swayosd-libinput-backend.service"; \
3933 } > /etc/systemd/system-preset/40-alloy-server.preset; \
3934 echo "server: pruned the desktop skeleton, wrote the preset overlay"; \
3935 else \
3936 test -e /etc/skel/.config/sway/config \
3937 || { echo "profile=client is missing the sway skeleton" >&2; exit 1; }; \
3938 test ! -e /etc/systemd/system-preset/40-alloy-server.preset \
3939 || { echo "profile=client picked up the server preset overlay" >&2; exit 1; }; \
3940 fi
3941
3942 # =====================================================================
3943 # The firewall's default zone, which is where the two profiles differ.
3944 #
3945 # The pick and its rejected alternatives are docs/STACK.md `## Firewall`.
3946 # Both profiles run firewalld; only the zone changes, and the zone is the
3947 # whole of the policy.
3948 #
3949 # CLIENT: Fedora's stock `public`, untouched. It allows ssh, mdns and
3950 # dhcpv6-client, which is exactly what 50-alloy.preset enables, so there is
3951 # no Alloy-authored policy to drift from the preset. `FedoraWorkstation` is
3952 # the zone this is NOT: it opens 1025-65535 on tcp and udp, and it is the
3953 # desktop spin's default, so leaving the zone unstated would have been a
3954 # plausible way to ship almost nothing. The client branch asserts the
3955 # default is still `public` rather than assuming Fedora keeps it there.
3956 #
3957 # SERVER: `alloy-server`, installed from usr/share/alloy/firewalld/. Same
3958 # set minus mdns, because this profile also runs on a public address.
3959 #
3960 # `firewall-offline-cmd` rather than `firewall-cmd`: there is no daemon in
3961 # a build, and the offline tool writes /etc/firewalld/firewalld.conf
3962 # directly. It also refuses a zone that does not parse, which is what makes
3963 # this a check on the shipped XML rather than a file copy.
3964 # =====================================================================
3965 RUN set -eu; \
3966 if [ "$PROFILE" = server ]; then \
3967 src=/usr/share/alloy/firewalld/alloy-server.xml; \
3968 test -f "$src" \
3969 || { echo "$src did not land from the config tree" >&2; exit 1; }; \
3970 install -m 0644 "$src" /etc/firewalld/zones/alloy-server.xml; \
3971 firewall-offline-cmd --set-default-zone=alloy-server >/dev/null; \
3972 [ "$(firewall-offline-cmd --get-default-zone)" = alloy-server ] \
3973 || { echo "the default zone did not take; the server would run the client's policy" >&2; exit 1; }; \
3974 firewall-offline-cmd --zone=alloy-server --query-service=ssh >/dev/null \
3975 || { echo "the server zone does not permit ssh; the profile with no console would be unreachable" >&2; exit 1; }; \
3976 ! firewall-offline-cmd --zone=alloy-server --query-service=mdns >/dev/null \
3977 || { echo "the server zone permits mdns; that subtraction is the reason this zone exists" >&2; exit 1; }; \
3978 echo "firewall: default zone alloy-server (ssh, dhcpv6-client)"; \
3979 else \
3980 test ! -e /etc/firewalld/zones/alloy-server.xml \
3981 || { echo "profile=client installed the server zone" >&2; exit 1; }; \
3982 [ "$(firewall-offline-cmd --get-default-zone)" = public ] \
3983 || { echo "the default zone is not public; Fedora moved it and the client policy is now whatever it moved to" >&2; exit 1; }; \
3984 firewall-offline-cmd --zone=public --query-service=mdns >/dev/null \
3985 || { echo "the public zone no longer permits mdns; the .local install flow would break" >&2; exit 1; }; \
3986 echo "firewall: default zone public (ssh, mdns, dhcpv6-client)"; \
3987 fi
3988
3989 # =====================================================================
3990 # The firewall's one exempt interface, asserted on both profiles.
3991 #
3992 # etc/firewalld/zones/trusted.xml overrides the package's copy and adds
3993 # `<interface name="tailscale0"/>`. Without it, firewalld drops every
3994 # inbound tailnet connection the moment it starts: tailscaled writes its own
3995 # nftables tables, and a base chain's accept in one table does not survive
3996 # another table's drop on the same hook. That is measured, in a network
3997 # namespace, and the file carries the detail.
3998 #
3999 # The failure this asserts against is silent and specific: a zone file that
4000 # is present, parses, and binds nothing. Reading the file back through
4001 # firewalld is the only way to tell that from a working one.
4002 # =====================================================================
4003 RUN set -eu; \
4004 zone=/etc/firewalld/zones/trusted.xml; \
4005 test -f "$zone" \
4006 || { echo "$zone did not land from the config tree" >&2; exit 1; }; \
4007 firewall-offline-cmd --zone=trusted --query-interface=tailscale0 >/dev/null \
4008 || { echo "tailscale0 is not bound to the trusted zone; enabling tailscaled would kill inbound tailnet traffic" >&2; exit 1; }; \
4009 [ "$(firewall-offline-cmd --zone=trusted --get-target)" = ACCEPT ] \
4010 || { echo "the trusted zone no longer accepts; the binding above would bind to a zone that drops" >&2; exit 1; }; \
4011 echo "firewall: tailscale0 bound to the trusted zone"
4012
4013 # =====================================================================
4014 # USBGuard is present and is NOT armed, asserted on both profiles.
4015 #
4016 # This is the unusual case where the assertion's job is to hold a feature
4017 # OFF. The ruling (GoingsOn alloy 63de3d4c) is enforcement on by default,
4018 # and the daemon is one preset line away from that — which is exactly the
4019 # problem, because the policy it would enforce today is the package's
4020 # stock one and the package's stock policy denies everything.
4021 #
4022 # Four facts, all read off the fedora-43 package on 2026-08-22 rather
4023 # than out of its documentation, and each is why one line below exists:
4024 #
4025 # ImplicitPolicyTarget=block a device matching no rule is blocked
4026 # PresentDevicePolicy=apply-policy including devices already attached
4027 # InsertedDevicePolicy=apply-policy and ones plugged in later
4028 # /etc/usbguard/rules.conf ships EMPTY, so nothing matches a rule
4029 #
4030 # Together those four are "deauthorize every USB device at boot". On a
4031 # desktop that is the keyboard, and the machine is then unrecoverable
4032 # without another one. So the shape of this check is: the package must be
4033 # here (the console's action half depends on it), the knobs must still
4034 # read the way they were measured (if Fedora changes one, the reasoning
4035 # above stops holding and someone must look again), and the unit must not
4036 # be enabled until the policy and the keyboard gate exist.
4037 #
4038 # The fourth line of that check — that the unit is not enabled — cannot
4039 # live here: `systemctl preset-all` runs several hundred lines further
4040 # down, so an assertion at this point would read the state before anything
4041 # had a chance to arm the daemon and would pass on an image that boots
4042 # armed. It sits immediately after preset-all instead.
4043 # =====================================================================
4044 RUN set -eu; \
4045 conf=/etc/usbguard/usbguard-daemon.conf; \
4046 test -f "$conf" \
4047 || { echo "$conf is missing; the usbguard package did not land" >&2; exit 1; }; \
4048 command -v usbguard >/dev/null \
4049 || { echo "the usbguard CLI is missing; alloy usb has nothing to front for its action half" >&2; exit 1; }; \
4050 for knob in ImplicitPolicyTarget=block PresentDevicePolicy=apply-policy InsertedDevicePolicy=apply-policy; do \
4051 grep -qx "$knob" "$conf" \
4052 || { echo "$conf no longer reads $knob; the default-deny reasoning in the package block was measured against it and needs re-reading" >&2; exit 1; }; \
4053 done; \
4054 test ! -s /etc/usbguard/rules.conf \
4055 || { echo "/etc/usbguard/rules.conf is no longer empty; Fedora shipped a policy and this image would enforce someone else's" >&2; exit 1; }; \
4056 echo "usbguard: installed, stock deny-all policy (the disarmed check runs after preset-all)"
4057
4058 # =====================================================================
4059 # The USB keyboard gate: on the client, and deliberately absent on the server.
4060 #
4061 # Step 4 of the `alloy usb` work, and the clause that makes deny-unknown safe
4062 # to arm. It drops USB enforcement whenever the machine has zero usable
4063 # keyboards and restores it when one appears. The reasoning, and the two
4064 # measurements it rests on, are in usr/bin/alloy-usb-gate; the short version is
4065 # that suspending enforcement means stopping the daemon AND re-authorizing the
4066 # bus, because usbguard restores nothing on its way out.
4067 #
4068 # THE SPLIT IS THE RULING'S, and it is the opposite of what "the client is the
4069 # careful profile" would suggest. A server-profile machine has no keyboard by
4070 # design, so a gate counting keyboards would find zero on a healthy box and hold
4071 # enforcement off forever, on exactly the machines that are physically exposed
4072 # and hardest to visit. That profile enforces unconditionally and its recovery
4073 # path is the provider console or a KVM.
4074 #
4075 # So the three files ride in from the COPY layers on both profiles, and the
4076 # server branch takes them out again. Both branches assert, per the rule this
4077 # file follows throughout and tests/profile_split.rs enforces: the client proves
4078 # the gate is really there, and the server proves it is really gone rather than
4079 # trusting that a branch it did not take was the right one.
4080 #
4081 # The client branch also checks the udev rule's number, which is load-bearing
4082 # rather than cosmetic. The property the script reads is ID_INPUT_KEYBOARD, and
4083 # udev's input_id builtin is what sets it; a rule sorting ahead of that reads
4084 # the property before anything wrote it, counts zero keyboards on a machine full
4085 # of them, and opens the gate on every boot.
4086 # =====================================================================
4087 RUN set -eu; \
4088 gate=/usr/bin/alloy-usb-gate; \
4089 unit=/etc/systemd/system/alloy-usb-gate.service; \
4090 rule=/etc/udev/rules.d/70-alloy-usb-gate.rules; \
4091 if [ "$PROFILE" = client ]; then \
4092 test -x "$gate" \
4093 || { echo "$gate is missing or not executable; deny-unknown has no release valve on the profile that has a keyboard to lose" >&2; exit 1; }; \
4094 sh -n "$gate" \
4095 || { echo "$gate does not parse; the release valve is a syntax error and nobody finds out until a machine has no keyboard" >&2; exit 1; }; \
4096 test -f "$unit" \
4097 || { echo "$unit is missing; 50-alloy.preset enables a unit that does not exist and the gate never runs" >&2; exit 1; }; \
4098 test -f "$rule" \
4099 || { echo "$rule is missing; the gate would be boot-only, and a keyboard that dies mid-session is the same lockout" >&2; exit 1; }; \
4100 grep -q 'ID_INPUT_KEYBOARD' "$gate" \
4101 || { echo "$gate no longer reads ID_INPUT_KEYBOARD; ID_INPUT_KEY is set by power buttons and consumer controls, and counting those keeps the gate shut on a machine with no keyboard" >&2; exit 1; }; \
4102 case "$(basename "$rule")" in \
4103 [7-9][0-9]-*) : ;; \
4104 *) echo "$rule sorts before udev's input_id builtin, so ID_INPUT_KEYBOARD is unset when the script reads it and every boot counts zero keyboards" >&2; exit 1 ;; \
4105 esac; \
4106 echo "usb gate: installed on the client profile"; \
4107 else \
4108 rm -f "$gate" "$unit" "$rule"; \
4109 test ! -e "$unit" \
4110 || { echo "profile=server still carries the USB keyboard gate; a machine with no keyboard by design would hold enforcement off forever" >&2; exit 1; }; \
4111 test ! -e "$gate" \
4112 || { echo "profile=server still carries $gate" >&2; exit 1; }; \
4113 test ! -e "$rule" \
4114 || { echo "profile=server still carries the gate's udev rule, which would start a unit that is no longer there on every input event" >&2; exit 1; }; \
4115 echo "usb gate: correctly absent on a profile with no keyboard to lock out"; \
4116 fi
4117
4118 # =====================================================================
4119 # Machine identity, from the builder: hostname and ssh pubkey.
4120 # =====================================================================
4121 # The minting half of `alloy image` (wiki `alloy-image-minting`, folded
4122 # into the builder by GO alloy 1372b159). Both values are PUBLIC, which
4123 # is the property the whole design rests on: the artifact holds no
4124 # secrets, so it can be kept, copied or rebuilt without care and a leak
4125 # of it costs nothing. Do not add anything here that changes that.
4126 #
4127 # The hostname is baked because it is how a headless box is found. avahi
4128 # publishes `<name>.local` and NetworkManager sends the same name in DHCP
4129 # option 12, so the machine appears in the router's lease table too. Known
4130 # in advance means the instruction to reach it is exact rather than a
4131 # hunt, and it means two minted machines never collide.
4132 #
4133 # The pubkey is the installer's only credential. No password auth, no
4134 # one-time code on a console the machine does not have, no pairing
4135 # window in which a box on the LAN is takeoverable. Explicitly NOT the
4136 # subiquity pattern.
4137 #
4138 # Both default empty, which is a real state: an image built without them
4139 # is the ordinary desktop install where a person is sitting at the
4140 # machine and types their own answers.
4141 ARG ALLOY_HOSTNAME=
4142 ARG ALLOY_SSH_KEY=
4143
4144 # The hostname is baked by rewriting DEFAULT_HOSTNAME in os-release, and
4145 # NOT by writing /etc/hostname. Two measurements, 2026-08-09, forced that:
4146 #
4147 # 1. podman bind mounts /etc/hostname into the container for the duration
4148 # of every RUN, so a write there lands on a throwaway file and is
4149 # discarded at commit. The step printed "identity: hostname alloytest",
4150 # exited 0, and shipped an image that still said `alloy`. Silent,
4151 # because nothing reads the value back at build time.
4152 #
4153 # 2. Writing /usr/lib/hostname instead — systemd's vendor default — does
4154 # not help either, and this is the part that is easy to get wrong twice.
4155 # systemd consults it only when /etc/hostname is ABSENT, and podman
4156 # leaves its mount target behind as an empty 0700 file in the layer. An
4157 # empty /etc/hostname is not a hostname, so systemd falls through to the
4158 # default; it does not go on to read /usr/lib/hostname. `rm` cannot
4159 # clear it either: the file is a live mount during the RUN and unlink
4160 # fails with EBUSY. A container build cannot produce an image whose
4161 # /etc/hostname is missing.
4162 #
4163 # DEFAULT_HOSTNAME is what systemd fell through TO, so it is the one lever
4164 # a RUN can still reach: /usr/lib/os-release arrives by `COPY usr/ /usr/`
4165 # above and no bind mount covers it. It is also the right meaning — a
4166 # fallback for a machine with no static hostname set, which is exactly what
4167 # a freshly minted medium is. The installer still wins on the machine it
4168 # installs: `systemd-firstboot --hostname` writes a real /etc/hostname
4169 # there. Everything downstream (avahi's `<name>.local`, NetworkManager's
4170 # DHCP option 12) asks hostnamed, so it sees this.
4171 #
4172 # `etc/hostname` stays out of this repo's `etc/` tree for the same reason:
4173 # `COPY etc/ /etc/` is not a RUN and does land, so shipping one would put a
4174 # real static hostname on top of the baked fallback and undo all of this.
4175 # ------------------------------------------------------------------ the name
4176 # the firmware shows -----------------------------------------------------
4177 #
4178 # The third place the image said Fedora, and the only one a user meets before
4179 # anything of ours has run. alloy@a558726 gave the system one identity at
4180 # /usr/lib/os-release and alloy@a558726's successor fixed GRUB's own menu; this
4181 # is one layer earlier than either, in the firmware's boot menu.
4182 #
4183 # HOW THE NAME GETS THERE. shim ships BOOTX64.CSV beside itself. When the
4184 # machine has no NVRAM entry pointing at shim, the firmware runs
4185 # \EFI\BOOT\BOOTX64.EFI, which is shim, which chain-loads fbx64.efi, which reads
4186 # this CSV and creates the entry. Field 1 is the binary to point at and field 2
4187 # is the description the boot menu shows. Stock, it reads
4188 # `shimx64.efi,Fedora,,This is the boot entry for Fedora`.
4189 #
4190 # THIS IS NOT SUFFICIENT ON ITS OWN, and this comment said it was until
4191 # 2026-09-07. It claimed the no-entry case was "every freshly installed machine,
4192 # because bootupd writes the ESP and calls no efibootmgr". Measured on a fresh
4193 # fw12 install that day: the installed ESP's CSV reads `shimx64.efi,Alloy,,...`
4194 # and `efibootmgr` reports two entries named Fedora. Something on the install
4195 # path makes them, so fbx64 never runs and this file is never read.
4196 #
4197 # What this RUN is still for: the machine whose NVRAM is cleared, which then
4198 # rebuilds its entry from the right CSV. The ordinary install is named by
4199 # usr/bin/alloy-boot-entry, which crates/alloy/src/install/plan.rs runs after
4200 # the deploy. The two are not redundant -- they cover different moments -- and
4201 # neither covers the other's.
4202 #
4203 # WHAT IS NOT TOUCHED, deliberately. The vendor directory stays EFI/fedora: the
4204 # path is baked into the signed shim, and renaming it is a Secure Boot question
4205 # rather than a branding one. Field 1 stays shimx64.efi for the same reason.
4206 # Only the description changes, and it is not signed content -- fbx64 reads it
4207 # as data.
4208 #
4209 # UTF-16LE with a BOM, because that is what fbx64 parses. Written with iconv
4210 # rather than by hand so the encoding is produced rather than hoped for, and
4211 # read back below, because a CSV that fails to parse leaves a machine whose
4212 # firmware entry is whatever the firmware invents.
4213 #
4214 # This does not reach the installer medium's own entry in a firmware menu. That
4215 # name is built from the USB device's descriptor -- measured 2026-09-07 under
4216 # OVMF, which showed `UEFI QEMU QEMU USB HARDDRIVE 1-0000:00:03.0-1` for our
4217 # medium -- and nothing written to a stick changes it.
4218 RUN set -eu; \
4219 csv=/usr/lib/bootupd/updates/EFI/fedora/BOOTX64.CSV; \
4220 [ -f "$csv" ] || { echo "no BOOTX64.CSV in the bootupd payload" >&2; exit 1; }; \
4221 printf 'shimx64.efi,Alloy,,This is the boot entry for Alloy\r\n' \
4222 | iconv -f UTF-8 -t UTF-16LE > /tmp/csv.body; \
4223 printf '\xff\xfe' > "$csv"; \
4224 cat /tmp/csv.body >> "$csv"; \
4225 rm -f /tmp/csv.body; \
4226 iconv -f UTF-16LE -t UTF-8 < "$csv" | grep -q 'shimx64\.efi,Alloy,,' \
4227 || { echo "the boot entry description did not land in BOOTX64.CSV" >&2; exit 1; }; \
4228 if grep -q Fedora "$csv"; then \
4229 echo "BOOTX64.CSV still names Fedora" >&2; exit 1; \
4230 fi; \
4231 echo "identity: firmware boot entry says Alloy"
4232
4233 RUN set -eu; \
4234 mkdir -p /usr/lib/alloy; \
4235 if [ -n "$ALLOY_HOSTNAME" ]; then \
4236 echo "$ALLOY_HOSTNAME" | grep -qE '^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$' \
4237 || { echo "ALLOY_HOSTNAME '$ALLOY_HOSTNAME' is not a hostname" >&2; exit 1; }; \
4238 sed -i "s/^DEFAULT_HOSTNAME=.*/DEFAULT_HOSTNAME=$ALLOY_HOSTNAME/" /usr/lib/os-release; \
4239 echo "identity: hostname $ALLOY_HOSTNAME"; \
4240 else \
4241 echo "identity: no hostname baked in"; \
4242 fi; \
4243 grep -q "^DEFAULT_HOSTNAME=${ALLOY_HOSTNAME:-alloy}$" /usr/lib/os-release \
4244 || { echo "the baked hostname did not land in os-release" >&2; exit 1; }; \
4245 if [ -n "$ALLOY_SSH_KEY" ]; then \
4246 case "$ALLOY_SSH_KEY" in \
4247 ssh-*|ecdsa-*|sk-*) ;; \
4248 *) echo "ALLOY_SSH_KEY is not an SSH public key. If this is a PRIVATE key, do not bake it in." >&2; exit 1 ;; \
4249 esac; \
4250 case "$ALLOY_SSH_KEY" in \
4251 *PRIVATE*) echo "ALLOY_SSH_KEY looks like a private key; refusing" >&2; exit 1 ;; \
4252 esac; \
4253 printf '%s\n' "$ALLOY_SSH_KEY" > /usr/lib/alloy/authorized_keys; \
4254 chmod 0644 /usr/lib/alloy/authorized_keys; \
4255 echo "identity: one public key baked in"; \
4256 else \
4257 echo "identity: no key baked in"; \
4258 fi
4259
4260 # =====================================================================
4261 # The answer sheet — the install questions the builder already answered.
4262 # =====================================================================
4263 # `alloy install` asks five questions, and on a machine the builder already
4264 # knows about most of them have one right answer, decided when the medium was
4265 # minted. These arguments are those answers, and the RUN below writes them to
4266 # /usr/lib/alloy/answers.toml, which crates/alloy/src/preseed.rs reads once when
4267 # the wizard opens. A step the sheet answers in full is skipped.
4268 #
4269 # This is the same idea as the pubkey above and lives beside it deliberately:
4270 # what the builder knew, carried on the medium so the installer does not ask
4271 # again. Alloy is distributed as a builder rather than as an image (wiki
4272 # `alloy-distribution`), so the person minting is the person installing, and
4273 # their recipe is their answer sheet. Per-host recipes live in build/hosts/.
4274 #
4275 # **No secrets, and this is the line that must not move.** Not the account
4276 # password, not the LUKS passphrase. The identity block above states the
4277 # invariant: the image "can be kept, copied or rebuilt without care and a leak
4278 # of it costs nothing". A passphrase written here would be in every layer cache,
4279 # every `podman save` and on every stick written from the medium. So the account
4280 # and encryption steps are still asked, with everything except the secret
4281 # already filled in, and `ALLOY_ENCRYPT=yes` answers only the checkbox.
4282 #
4283 # ALLOY_DISK is a rule and never a device path. A medium that erases
4284 # /dev/nvme0n1 without asking is one wrong laptop away from erasing the wrong
4285 # machine; a rule that stops matching falls back to asking, which is the
4286 # behaviour the installer's tests pin.
4287 ARG ALLOY_USERNAME=
4288 ARG ALLOY_ENCRYPT=
4289 ARG ALLOY_LOCATE_TIMEZONE=
4290 ARG ALLOY_DISK=
4291
4292 RUN set -eu; \
4293 mkdir -p /usr/lib/alloy; \
4294 sheet=/usr/lib/alloy/answers.toml; \
4295 rm -f "$sheet"; \
4296 # A yes/no argument that is neither is a typo, and a typo that defaulted
4297 # quietly would decide encryption for a machine nobody asked. Refuse it.
4298 bool() { \
4299 case "$2" in \
4300 yes|true|on) echo "$1 = true" >> "$sheet" ;; \
4301 no|false|off) echo "$1 = false" >> "$sheet" ;; \
4302 *) echo "$1 is '$2'; expected yes or no" >&2; exit 1 ;; \
4303 esac; \
4304 }; \
4305 if [ -n "$ALLOY_HOSTNAME" ]; then \
4306 echo "hostname = \"$ALLOY_HOSTNAME\"" >> "$sheet"; \
4307 fi; \
4308 if [ -n "$ALLOY_USERNAME" ]; then \
4309 echo "$ALLOY_USERNAME" | grep -qE '^[a-z_][a-z0-9_-]{0,31}$' \
4310 || { echo "ALLOY_USERNAME '$ALLOY_USERNAME' is not a username" >&2; exit 1; }; \
4311 echo "username = \"$ALLOY_USERNAME\"" >> "$sheet"; \
4312 fi; \
4313 if [ -n "$ALLOY_DISK" ]; then \
4314 case "$ALLOY_DISK" in \
4315 single-internal|single-internal-nvme) ;; \
4316 *) echo "ALLOY_DISK '$ALLOY_DISK' is not a rule; expected single-internal or single-internal-nvme. A device path is deliberately not accepted here" >&2; exit 1 ;; \
4317 esac; \
4318 echo "disk = \"$ALLOY_DISK\"" >> "$sheet"; \
4319 fi; \
4320 [ -z "$ALLOY_ENCRYPT" ] || bool encrypt "$ALLOY_ENCRYPT"; \
4321 [ -z "$ALLOY_LOCATE_TIMEZONE" ] || bool locate_timezone "$ALLOY_LOCATE_TIMEZONE"; \
4322 if [ -f "$sheet" ]; then \
4323 chmod 0644 "$sheet"; \
4324 # The hostname is written twice from one argument, here and into
4325 # DEFAULT_HOSTNAME above, so the installer's default and its statement
4326 # that the question is answered cannot disagree. Asserted rather than
4327 # trusted, because they are two `sed`-shaped writes in two steps.
4328 if [ -n "$ALLOY_HOSTNAME" ]; then \
4329 grep -q "^hostname = \"$ALLOY_HOSTNAME\"$" "$sheet" \
4330 && grep -q "^DEFAULT_HOSTNAME=$ALLOY_HOSTNAME$" /usr/lib/os-release \
4331 || { echo "the answer sheet and os-release disagree about the hostname" >&2; exit 1; }; \
4332 fi; \
4333 echo "answers: $(wc -l < "$sheet") prefilled"; \
4334 sed 's/^/answers: /' "$sheet"; \
4335 else \
4336 echo "answers: none, every question will be asked"; \
4337 fi
4338
4339 # =====================================================================
4340 # The build stamp — which build of the product this image is.
4341 # =====================================================================
4342 # Three numbers describe an Alloy machine and they move on three different
4343 # clocks, which is why they are three fields and not one:
4344 #
4345 # VERSION_ID the product. Moves on a release, by hand.
4346 # IMAGE_VERSION the build. Moves every build, stamped here.
4347 # ALLOY_BASE the Fedora base. Moves when the FROM line does.
4348 #
4349 # VERSION and PRETTY_NAME are freeform display and carry all three, which
4350 # is what a support conversation reads back.
4351 #
4352 # The stamp is not in the committed os-release. A placeholder there would
4353 # be a lie on any machine where this step silently stopped working, and
4354 # `usr/lib/os-release` ships `(build <n>, ...)` precisely so that a literal
4355 # `<n>` reaching an installed machine is a visible failure rather than a
4356 # plausible number. Both branches below rewrite it and the grep at the end
4357 # proves it is gone.
4358 #
4359 # Empty default is a real state: a bare `podman build` with no wrapper
4360 # produces an honestly unstamped image, and keeps its layer cache, since an
4361 # always-changing value here would invalidate every step after it. The
4362 # wrapper scripts (build/build-image.sh, build/build-iso.sh) always pass
4363 # one, so anything that can become an artifact is stamped.
4364 #
4365 # Format is <date>.<serial>: a commit count was considered and rejected
4366 # because it is identical across rebuilds of one commit, which is exactly
4367 # the pair this field exists to tell apart. The wrappers use the UTC time
4368 # of day as the serial, so same-day rebuilds differ with no state kept
4369 # anywhere; the validation below accepts any digits, so a builder that
4370 # wants a plain `.1` can pass one.
4371 ARG ALLOY_BUILD_STAMP=
4372
4373 RUN set -eu; \
4374 if [ -n "$ALLOY_BUILD_STAMP" ]; then \
4375 echo "$ALLOY_BUILD_STAMP" | grep -qE '^[0-9]{8}\.[0-9]+$' \
4376 || { echo "ALLOY_BUILD_STAMP '$ALLOY_BUILD_STAMP' is not <YYYYMMDD>.<serial>" >&2; exit 1; }; \
4377 sed -i "s/^VERSION=\"\\(.*\\)build <n>\\(.*\\)\"$/VERSION=\"\\1build $ALLOY_BUILD_STAMP\\2\"/; \
4378 s/^PRETTY_NAME=\"\\(.*\\)build <n>\\(.*\\)\"$/PRETTY_NAME=\"\\1build $ALLOY_BUILD_STAMP\\2\"/" \
4379 /usr/lib/os-release; \
4380 sed -i "/^VERSION_ID=/a IMAGE_VERSION=\"$ALLOY_BUILD_STAMP\"" /usr/lib/os-release; \
4381 grep -q "^IMAGE_VERSION=\"$ALLOY_BUILD_STAMP\"$" /usr/lib/os-release \
4382 || { echo "the build stamp did not land in os-release" >&2; exit 1; }; \
4383 echo "version: build $ALLOY_BUILD_STAMP"; \
4384 else \
4385 sed -i 's/^\(VERSION\|PRETTY_NAME\)="\(.*\)build <n>, \(.*\)"$/\1="\2\3"/' \
4386 /usr/lib/os-release; \
4387 echo "version: unstamped build"; \
4388 fi; \
4389 if grep -q '<n>' /usr/lib/os-release; then \
4390 echo "the build placeholder is still in os-release" >&2; exit 1; \
4391 fi; \
4392 grep -q "^VERSION_ID=" /usr/lib/os-release \
4393 || { echo "os-release lost its VERSION_ID" >&2; exit 1; }; \
4394 grep -q "^ALLOY_BASE=" /usr/lib/os-release \
4395 || { echo "os-release lost its ALLOY_BASE" >&2; exit 1; }
4396
4397 # =====================================================================
4398 # The build record — the choices that made this image.
4399 # =====================================================================
4400 # The other half of `alloy image`. The builder writes its choices to
4401 # build/alloy-build.toml in the checkout; this writes the same document
4402 # into the image, so a rebuild started on the machine itself begins from
4403 # what that machine already is rather than from defaults. Wiki
4404 # `alloy-distribution`: "choices recorded into the built image, read back
4405 # from the previous one".
4406 #
4407 # Written from the ARGs rather than copied from the checkout, and that is
4408 # the point: the record then describes what was actually built. A copy
4409 # would describe what the TUI last saved, which is the same thing right
4410 # up until someone passes `--build-arg` by hand or edits the file between
4411 # saving and building.
4412 #
4413 # THIS IS THE CHOICES HALF AND NOT A LOCKFILE. It does not pin the
4414 # resolved RPM set, so rebuilding from it reproduces the same decisions
4415 # against today's packages, not the same image. The resolutions half is
4416 # still open, and the reason is on the task: Fedora keeps only the newest
4417 # build of each package, so a NEVRA list expires on its own schedule.
4418 # Saying so in the file itself, because a file called a record invites
4419 # being read as a lockfile.
4420 #
4421 # crates/alloy/tests/build_record.rs parses what this writes with the
4422 # console's own parser, so the shell here and the Rust there cannot
4423 # drift into disagreeing about the format.
4424 RUN set -eu; \
4425 mkdir -p /usr/lib/alloy; \
4426 langs=""; \
4427 for lang in $(echo "$LANGS" | tr ',' ' '); do \
4428 langs="$langs, \"$lang\""; \
4429 done; \
4430 langs="[${langs#, }]"; \
4431 { echo "# Alloy build record — the choices that made this image."; \
4432 echo "# Written by the build, read back by \`alloy image\` so a rebuild starts"; \
4433 echo "# from what this machine already is rather than from defaults."; \
4434 echo "#"; \
4435 echo "# This is the CHOICES half. It is not a lockfile: it does not pin the"; \
4436 echo "# resolved RPM set, so rebuilding from it gives you the same decisions"; \
4437 echo "# against today's packages, not the same image. See wiki"; \
4438 echo "# \`alloy-distribution\` for why the resolutions half is still open."; \
4439 echo ""; \
4440 echo "profile = \"$PROFILE\""; \
4441 echo "browser = \"$BROWSER\""; \
4442 echo "langs = $langs"; \
4443 echo "trim = \"$TRIM\""; \
4444 echo "db = \"$DB\""; \
4445 echo "hostname = \"$ALLOY_HOSTNAME\""; \
4446 echo "pubkey = \"\""; \
4447 } > /usr/lib/alloy/build.toml; \
4448 grep -q "^profile = \"$PROFILE\"$" /usr/lib/alloy/build.toml \
4449 || { echo "the build record does not name the profile it was built with" >&2; exit 1; }; \
4450 echo "record: /usr/lib/alloy/build.toml"
4451
4452 # `artifact` is deliberately absent from the record above, and `pubkey` is
4453 # deliberately empty rather than omitted.
4454 #
4455 # An image does not know which artifact it was packed into. The same image
4456 # becomes an ISO, a raw disk or a qcow2, and a record claiming one of them
4457 # would be asserting something the build genuinely cannot see. The parser
4458 # treats a missing key as the default, which is the honest answer.
4459 #
4460 # `pubkey` is a path on whichever machine ran the builder, and that path
4461 # means nothing here — the key itself is at authorized_keys, and the path
4462 # it came from is not a fact about this image. Empty rather than absent so
4463 # the shape of the document is the same either way, and so nobody reads a
4464 # missing key as a key that was lost.
4465
4466 # The profile, recorded where the console can read it.
4467 #
4468 # `alloy` hides the verbs that need a compositor (`display`, and the bar half
4469 # of `status`) when this says `server`. Build-time knowledge rather than a
4470 # runtime probe: the image already knows what it is, and probing for a
4471 # compositor would answer "no" on a client machine that is merely at the
4472 # greeter, which is a different thing entirely.
4473 #
4474 # /usr/lib rather than /etc: it describes the image, not the machine's
4475 # configuration, and nothing should edit it after the build. Absent means
4476 # client, which is what a `cargo run` on a dev box gets.
4477 RUN set -eu; \
4478 mkdir -p /usr/lib/alloy; \
4479 printf '%s\n' "$PROFILE" > /usr/lib/alloy/profile; \
4480 test "$(cat /usr/lib/alloy/profile)" = "$PROFILE" \
4481 || { echo "the profile marker did not land" >&2; exit 1; }
4482
4483 # The profile, recorded where every os-release reader can see it.
4484 #
4485 # The committed file carries `<profile>` on both fields, the same
4486 # placeholder idiom as `build <n>`: a value that never got rewritten reads
4487 # as obviously broken rather than as a plausible wrong answer. What stood
4488 # there before was `VARIANT_ID=base`, which every image of both profiles
4489 # shipped, so anything keying off it (a dotfile, a support question, a
4490 # `systemd-analyze` dump) was told nothing.
4491 #
4492 # Downstream of the PROFILE `case` near the top, so the value is already
4493 # known to be one of the two words and the display half can be written by
4494 # hand rather than derived.
4495 #
4496 # The profile alone, never the tag variants. `firewall-server` and
4497 # `usbgate-server` are image tags chosen by whoever runs the build; the
4498 # Containerfile is never told which one it is making, so there is nothing
4499 # here to stamp them from.
4500 RUN set -eu; \
4501 case "$PROFILE" in \
4502 client) variant="Client" ;; \
4503 server) variant="Server" ;; \
4504 *) echo "unknown PROFILE '$PROFILE' reached the os-release stamp" >&2; exit 1 ;; \
4505 esac; \
4506 sed -i "s/^VARIANT=.*/VARIANT=\"$variant\"/; \
4507 s/^VARIANT_ID=.*/VARIANT_ID=$PROFILE/" /usr/lib/os-release; \
4508 grep -q "^VARIANT_ID=$PROFILE$" /usr/lib/os-release \
4509 || { echo "the profile did not land in os-release" >&2; exit 1; }; \
4510 grep -q "^VARIANT=\"$variant\"$" /usr/lib/os-release \
4511 || { echo "the profile display name did not land in os-release" >&2; exit 1; }; \
4512 if grep -q '<profile>' /usr/lib/os-release; then \
4513 echo "the profile placeholder is still in os-release" >&2; exit 1; \
4514 fi
4515
4516 RUN systemctl preset-all
4517
4518 # =====================================================================
4519 # ...and usbguard is still disarmed, which only this side of preset-all
4520 # can say.
4521 #
4522 # The package block and the config assertion above establish that the
4523 # daemon would enforce a deny-everything policy if it ran. This is the
4524 # line that says it does not run. It has to be here rather than beside
4525 # them: `preset-all` is what turns a preset line into an enable symlink,
4526 # so before it every unit in the image reads disabled and the check would
4527 # pass without meaning anything.
4528 #
4529 # Two ways it could arm, and this catches both. A line added to
4530 # 50-alloy.preset — the step-3-and-4 work will eventually add exactly
4531 # that, and this assertion is the thing it has to consciously delete.
4532 # And Fedora's own 90-default.preset, if the usbguard package ever starts
4533 # shipping an enable line of its own; today it does not, and that is a
4534 # fact about someone else's file rather than one about ours.
4535 # =====================================================================
4536 RUN set -eu; \
4537 ! systemctl is-enabled usbguard.service >/dev/null 2>&1 \
4538 || { echo "usbguard.service is enabled, and /etc/usbguard/rules.conf is empty with ImplicitPolicyTarget=block: this image deauthorizes its own keyboard at boot" >&2; exit 1; }; \
4539 echo "usbguard: disarmed after preset-all"
4540
4541 # Template instances have to be enabled by name. `preset-all` iterates over
4542 # the unit *files* that exist, and `alloy-debug-shell@.service` is a template
4543 # with no instance of its own, so a preset line naming `@tty9` matches nothing
4544 # and is silently ignored: no symlink, no unit, and a kernel flag that appears
4545 # to do nothing at all. Found by booting with `alloy.debug` set and getting a
4546 # plain login prompt.
4547 #
4548 # Both instances are inert unless the kernel command line carries
4549 # `alloy.debug` (see the unit's ConditionKernelCommandLine), which only the
4550 # installer ISO's verbose entries set, so enabling them in every image is
4551 # safe.
4552 RUN systemctl enable alloy-debug-shell@tty9.service alloy-debug-shell@ttyS0.service
4553
4554 # =====================================================================
4555 # Branding
4556 # =====================================================================
4557 # plymouth splash and wallpapers ship via the config tree above.
4558 #
4559 # os-release ships at /usr/lib/os-release, not /etc/os-release, and this
4560 # link is what makes the two agree. It shipped at /etc for a while, which
4561 # left the image carrying two identities: /etc said Alloy and
4562 # /usr/lib said Fedora, because `COPY etc/ /etc/` replaced the base's
4563 # symlink with a regular file. Which name a consumer saw then depended on
4564 # which path it happened to read, and the ones reading /usr/lib were told
4565 # they were running Fedora. ostree is one of them: it builds the boot
4566 # menu entry from the deployment's /usr/lib/os-release, so every installed
4567 # machine offered "Fedora Linux 43 (Forty Three)" at GRUB, which is the
4568 # last surface after the installer that still said Fedora. See GO task
4569 # 7869c992.
4570 #
4571 # The link direction follows the spec and the base: /usr/lib holds the
4572 # file, /etc points at it, the same arrangement fedora-release, issue and
4573 # redhat-release already use. Relative, not absolute, so it resolves
4574 # inside a mounted deployment rather than against the host's /usr.
4575 RUN ln -sfn ../usr/lib/os-release /etc/os-release
4576
4577 # =====================================================================
4578 # Disable third-party repos post-install.
4579 # =====================================================================
4580 # terra, the Rust-Wayland COPRs, and Tailscale are needed only at
4581 # build time to layer packages Fedora doesn't carry. Left enabled they
4582 # earn nothing at runtime (a bootc image updates by whole-image pull,
4583 # not per-package dnf) and actively break bootc-image-builder: its
4584 # installer depsolve reads every enabled repo but runs in its own
4585 # environment, where these repos' file:// GPG keys and metalinks can't
4586 # be resolved. Disable them so only the Fedora repos remain live; the
4587 # already-installed packages are unaffected.
4588 RUN sed -i 's/^enabled=1/enabled=0/; s/^enabled_metadata=1/enabled_metadata=0/' \
4589 /etc/yum.repos.d/terra.repo \
4590 /etc/yum.repos.d/tailscale.repo \
4591 /etc/yum.repos.d/_copr:*.repo
4592
4593 # =====================================================================
4594 # Pin dnf's $releasever to the Fedora base version.
4595 # =====================================================================
4596 # Alloy's os-release carries its own product VERSION_ID, so dnf/librepo
4597 # would otherwise expand $releasever to it and request a nonexistent
4598 # fedora-<product> repo (404). Every consumer of the Fedora repos —
4599 # rpm-ostree package layering on the installed system, and
4600 # bootc-image-builder's installer depsolve — needs this pinned to the
4601 # actual base version, independent of Alloy's product version.
4602 #
4603 # Deliberately not derived from ALLOY_BASE, which states the same number
4604 # in os-release. This is a file and overrides $releasever expansion
4605 # whatever os-release says, which is the property wanted: a pin that can
4606 # be read out of the thing it exists to override is not a pin. The two
4607 # move together by hand when the FROM line moves.
4608 RUN echo 43 > /etc/dnf/vars/releasever
4609
4610 # =====================================================================
4611 # The Alloy console and terminal, as carried packages.
4612 # =====================================================================
4613 # Neither binary is in this image. Until 2026-08-14 both were COPYed into
4614 # /usr/bin, and that is the one shape in which a component can never be
4615 # fixed on a machine somebody already installed: a layer over an unowned
4616 # file does not fail to depsolve, it dies in checkout with "File exists".
4617 # The rust-build stage's "component repo" block above carries the reasoning
4618 # and build/layertest holds the measurements.
4619 #
4620 # So the image carries the packages and installs neither.
4621 # alloy-layer-components.service lays them down on the first boot after an
4622 # install, from this repo, with no network. That unit has shipped inert
4623 # since alloy@0e9d9c5, gated on the repo being present and /usr/bin/alloy
4624 # being absent; this is the commit that makes the second condition true.
4625 #
4626 # Last before the lint on purpose, and later than the config tree the
4627 # header calls the most-changed layer — the console changes with every
4628 # commit to crates/, which is more often still. Nothing below it means a
4629 # console-only rebuild reuses every package and config layer above.
4630 COPY --from=rust-build /staged-rpm /usr/share/alloy/rpm
4631
4632 # enabled=1, and it is forced rather than chosen. `rpm-ostree install` on a
4633 # booted system talks to rpm-ostreed over D-Bus, and its --enablerepo is
4634 # "only supported in a container build" — so a repo shipped disabled is a
4635 # repo the first-boot unit cannot turn on for its own transaction, and the
4636 # machine comes up with no console. The "enabled by default or opt-in"
4637 # question was about the future NETWORK repo, where the answer was a real
4638 # choice, and it is answered: opt-in, `enabled=0`, ruled 2026-08-17. This one
4639 # reaches no network and answers to nothing but the local filesystem, so the
4640 # ruling does not reach it.
4641 #
4642 # gpgcheck=0 is unfinished work rather than a position, and the position is
4643 # now decided: this repo gets signed too. Max ruled it 2026-08-17 over the
4644 # recommendation, which was to leave the carried repo explicitly unsigned —
4645 # these files came in with the image and are only as trustworthy as it is, so
4646 # a signature defends nothing the image's own integrity does not already
4647 # defend. What the ruling buys is one code path with the network repo, so
4648 # there is no branch only that path exercises and the key exists before it is
4649 # needed under pressure. "The image is the trust boundary" is a claim signing
4650 # should make explicitly rather than one this file should assume.
4651 #
4652 # What is left: the key, then `gpgcheck=1` and a `gpgkey` here, the public key
4653 # shipped in the image, and a test that fails the build if this repo does not
4654 # verify. GoingsOn alloy d866e125.
4655 RUN printf '[alloy-local]\nname=Alloy components, carried with the image\nbaseurl=file:///usr/share/alloy/rpm\nenabled=1\ngpgcheck=0\n' \
4656 > /etc/yum.repos.d/alloy-local.repo
4657
4658 # What the first-boot unit layers, as a list the image writes rather than a
4659 # package set the unit hardcodes. That is what makes the profile split work
4660 # at all: `server` has no compositor, so a Wayland terminal has nothing to
4661 # connect to and must not be laid down, and the unit is one static file
4662 # shared by both profiles. Writing the list here keeps the decision where
4663 # every other profile decision in this file already lives.
4664 #
4665 # Both RPMs are carried on both profiles even so. Dropping shop's file on
4666 # `server` would leave the repo metadata describing a package that is not
4667 # there, which is a landmine for anyone who later resolves against it, and
4668 # regenerating metadata would want createrepo_c in the runtime image for
4669 # the sake of deleting three megabytes. A server operator who wants the
4670 # terminal can install it; nothing lays it down for them.
4671 #
4672 # THAT INSTALL RESOLVES OVER THE NETWORK, and it is worth saying because the
4673 # carried repo is a file:// one and reads as though it were self-contained.
4674 # `build/rpm/shop.spec` names its dependencies by package (`fontconfig`,
4675 # `libxkbcommon`, `libwayland-client`, `libwayland-egl`, `vulkan-loader`,
4676 # `libglvnd-egl`), and every one of those arrives on `client` through the
4677 # graphical stack this profile does not install. `fontconfig` joined that list
4678 # on 2026-08-19, when the font block above went client-only. So the affordance
4679 # is "the package is here and dnf can resolve the rest", not "this installs
4680 # offline". Which of the six the base itself already carries was not measured:
4681 # it needs a built server image and this change was made without one.
4682 RUN if [ "$PROFILE" = client ]; then \
4683 printf 'alloy\nshop\n' > /usr/share/alloy/components; \
4684 grep -qx shop /usr/share/alloy/components \
4685 || { echo "profile=client would come up with no terminal" >&2; exit 1; }; \
4686 else \
4687 printf 'alloy\n' > /usr/share/alloy/components; \
4688 ! grep -qx shop /usr/share/alloy/components \
4689 || { echo "profile=server would layer a Wayland terminal on a machine with no compositor" >&2; exit 1; }; \
4690 fi
4691
4692 # The carried repo is complete, and neither binary leaked into the image.
4693 #
4694 # Unconditional, and both halves matter. A repo that lost its metadata
4695 # resolves nothing, and the unit's failure mode is a machine that boots to a
4696 # greeter, opens a session with no console and no terminal, and offers no way
4697 # to fix either from inside it. A /usr/bin/alloy that came back — a restored
4698 # COPY, a `dnf install` somewhere above — is quieter and worse: the unit's
4699 # second condition goes false, it never runs, the machine works, and the
4700 # console it carries can never be replaced. That is the whole failure this
4701 # design exists to prevent, and it would ship looking healthy.
4702 RUN set -eux; \
4703 test -f /usr/share/alloy/rpm/repodata/repomd.xml \
4704 || { echo "the carried repo has no metadata; first boot would layer nothing" >&2; exit 1; }; \
4705 test -n "$(ls /usr/share/alloy/rpm/*.rpm)" \
4706 || { echo "the carried repo holds no packages" >&2; exit 1; }; \
4707 test ! -e /usr/bin/alloy \
4708 || { echo "the image carries /usr/bin/alloy; alloy-layer-components.service will never fire and the console can never be fixed" >&2; exit 1; }; \
4709 test ! -e /usr/bin/shop \
4710 || { echo "the image carries /usr/bin/shop; an unowned file cannot be layered over" >&2; exit 1; }; \
4711 test -x /usr/bin/alloy-layer-notice \
4712 || { echo "alloy-layer-notice is missing or not executable; the layering boot is a blank screen again" >&2; exit 1; }; \
4713 sh -n /usr/bin/alloy-layer-notice \
4714 || { echo "alloy-layer-notice does not parse; the layering boot is a blank screen again" >&2; exit 1; }; \
4715 test -x /usr/bin/alloy-layer-repos \
4716 || { echo "alloy-layer-repos is missing or not executable; the first boot would need name resolution to install packages that are on the disk" >&2; exit 1; }; \
4717 sh -n /usr/bin/alloy-layer-repos \
4718 || { echo "alloy-layer-repos does not parse; an offline first boot would come up with no console" >&2; exit 1; }; \
4719 grep -q '^enabled=1$' /etc/yum.repos.d/alloy-local.repo \
4720 || { echo "the carried repo is not enabled; the fenced transaction would have no source at all" >&2; exit 1; }
4721
4722 # The themes the console refuses to run without: with no theme file on any
4723 # search path it does not fall back, it exits.
4724 #
4725 # These stay in the base image while the binary that reads them travels as a
4726 # package, which is a deliberate split rather than an oversight. They are data
4727 # the whole image shares — the rendered skeleton above came out of the same two
4728 # files — so packaging them with the console would put one copy under rpm and
4729 # another under ostree and let them disagree.
4730 #
4731 # What it costs, and it is worth knowing before writing a hotfix: a console fix
4732 # that needs a NEW theme token cannot ship through the RPM channel, because the
4733 # token would have to arrive in the base. That makes it an image change, which
4734 # is a release rather than a hotfix (wiki `hotfix-policy`), so the constraint
4735 # and the policy already agree.
4736 COPY --from=rust-build /staged-themes /usr/share/alloy/themes
4737
4738 # The house faces: Quasi Mono for everything monospaced, Quasi Body for UI text.
4739 #
4740 # Cut in the rust stage rather than downloaded, because there is nothing to
4741 # download: a built face is not committed anywhere, so the pipeline is the only
4742 # source of truth for what a face contains (wiki `typography-standard`). What it
4743 # replaced was a 60 MB IosevkaTerm Nerd Font zip and an upstream Atkinson
4744 # tarball, and it retired the Private Use Area from the image entirely.
4745 #
4746 # Down here with the other staged copies rather than up in the old font layer:
4747 # this depends on the rust stage, so above the package installs it would rebuild
4748 # 7 GB of them every time a console edit changed the stage it comes from.
4749 #
4750 # The OFL text travels with the faces, in the same directory, because OFL 1.1
4751 # requires it to travel with a modified build and both of these are one.
4752 #
4753 # STAGED FIRST, THEN INSTALLED ON CLIENT ONLY. A `COPY` cannot be conditional,
4754 # so the faces land at a path outside /usr and the `RUN` below decides what
4755 # becomes of them: installed and cached on `client`, deleted on `server`, which
4756 # has nothing that can draw them (the fontconfig block above holds the whole
4757 # argument). /faces-staged is removed on both branches, because bootc lints the
4758 # root and a leftover directory there is a finding rather than a nuisance.
4759 #
4760 # Neither branch asserts the removal afterwards. Under `set -eu` a `test ! -e`
4761 # on the line after `rm -rf` can only fail if `rm` returned 0 without removing
4762 # anything, so it would be a line that cannot fail rather than a check. What
4763 # does hold the removal in place is `font_profile.rs`, which runs both branches
4764 # against a fake root and looks: delete the `rm` and the test says so.
4765 #
4766 # What the staging costs on `server` is 208 KiB in one layer of the image
4767 # tarball. It costs nothing in the installed system: the deployment is built
4768 # from the merged tree, and these files are not in it.
4769 COPY --from=rust-build /faces/ /faces-staged/
4770 RUN set -eu; \
4771 if [ "$PROFILE" = client ]; then \
4772 mkdir -p /usr/share/fonts/quasi; \
4773 cp /faces-staged/* /usr/share/fonts/quasi/; \
4774 rm -rf /faces-staged; \
4775 fc-cache -fv; \
4776 [ "$(fc-list ':family=Quasi Mono' family | wc -l)" -ge 1 ] \
4777 || { echo "the faces were copied and fontconfig cannot see Quasi Mono; the cache did not take" >&2; exit 1; }; \
4778 else \
4779 rm -rf /faces-staged; \
4780 test ! -e /usr/share/fonts/quasi \
4781 || { echo "profile=server installed the house faces; nothing here can rasterise one, and a face a machine cannot draw is payload with no reader" >&2; exit 1; }; \
4782 echo "fonts: the house faces are staged and discarded on this profile"; \
4783 fi
4784
4785 # The schemas the settings view's Applications tab is built from. Straight
4786 # from the build context: they are hand-authored TOML that nothing compiles,
4787 # so there is no staged copy to take them from.
4788 #
4789 # Missing until 2026-08-01, which meant `alloy settings` opened its
4790 # Applications tab on "no schemas found" for every installed machine. It was
4791 # invisible from a source checkout because the search path ends in a
4792 # CARGO_MANIFEST_DIR fallback, so a `cargo run` finds the repo's copies and an
4793 # image finds nothing.
4794 COPY schemas /usr/share/alloy/schemas
4795
4796 RUN test -n "$(ls -A /usr/share/alloy/schemas)" \
4797 || { echo "no config schemas shipped; the settings Applications tab would be empty" >&2; exit 1; }
4798
4799 # The tool that applies the greeter's console palette. The table itself arrives
4800 # with the rest of the rendered tree above, at /usr/share/alloy/vtrgb.
4801 # alloy-vtrgb.service reads it; setvtrgb ships in kbd, which systemd's vconsole
4802 # setup already pulls in. Assert it here rather than let the unit's
4803 # ConditionPathExists turn a missing binary into a silent no-op that drops the
4804 # greeter back to the stock console palette.
4805 RUN command -v setvtrgb >/dev/null \
4806 || { echo "setvtrgb (kbd) is missing; alloy-vtrgb.service would no-op" >&2; exit 1; }
4807 RUN test -s /usr/share/alloy/vtrgb \
4808 || { echo "the console palette did not arrive with the rendered tree" >&2; exit 1; }
4809
4810 # vtrgb.night is the dark console table, and nothing reads it yet. It is here so
4811 # that switching the greeter's palette is a unit change rather than a render
4812 # change: alloy-vtrgb.service runs before any user exists, so the per-user mode
4813 # file cannot reach it, and the system-scoped setting that will choose between
4814 # these two tables is still to come. Asserted anyway, because an unread file is
4815 # exactly the kind that quietly stops being produced. The note lives here rather
4816 # than in the template: templates/usr/share/alloy/vtrgb.in renders to nothing but
4817 # the table, and setvtrgb rejects any line that is not sixteen values.
4818 RUN test -s /usr/share/alloy/vtrgb.night \
4819 || { echo "the dark console palette did not arrive; the greeter would have nothing to switch to" >&2; exit 1; }
4820
4821 # The dark half of the skeleton, which `alloy theme apply` reads its whole
4822 # manifest from. An empty or missing tree makes the verb a no-op that says so
4823 # once per login and leaves every user on the light render forever, so it is
4824 # worth one test here rather than a report from a booted machine.
4825 #
4826 # The pairing was already proved in the rust-build stage; what this repeats it
4827 # for is the finished image, where /etc/skel is also written by every package
4828 # that ships a skeleton file and by every COPY above. A night render whose day
4829 # counterpart went missing between there and here is a file the pristine test
4830 # can only decide by keeping, so the user stays on whatever they have.
4831 #
4832 # Conditional since 2026-08-03, and it had to become one: the server prune
4833 # above deletes /usr/share/alloy/skel-night, so this test asserted the presence
4834 # of something the same file had already removed and `PROFILE=server` could not
4835 # build at all. Found by the first server build ever run, which is the whole
4836 # argument for running one. The server branch asserts the absence rather than
4837 # skipping, per the rule the split lives by — a branch that checks nothing is
4838 # the failure mode conditionals were supposed to be worth risking.
4839 RUN set -eux; \
4840 if [ "$PROFILE" = client ]; then \
4841 test -n "$(find /usr/share/alloy/skel-night -type f -print -quit)" \
4842 || { echo "the night skeleton did not arrive; alloy theme apply would have nothing to apply" >&2; exit 1; }; \
4843 for applied in $(find /usr/share/alloy/skel-night -type f); do \
4844 rel="${applied#/usr/share/alloy/skel-night/}"; \
4845 [ -f "/etc/skel/$rel" ] \
4846 || { echo "/etc/skel/$rel is gone; the night render of it has nothing to switch back to" >&2; exit 1; }; \
4847 done; \
4848 else \
4849 test ! -e /usr/share/alloy/skel-night \
4850 || { echo "profile=server still carries the night skeleton the prune removes" >&2; exit 1; }; \
4851 echo "skel-night: pruned with the rest of the desktop skeleton"; \
4852 fi
4853
4854 # Script coverage, and the two claims that come with it.
4855 #
4856 # This is the third arrival of one defect class, so it gets an assertion rather
4857 # than a comment. The emoji alias named a font the image never installed. The
4858 # swayosd unit sat at a path udev does not read. Both were configuration that was
4859 # correct about a thing that was absent, and in every case the build was green and
4860 # the failure was a user staring at missing glyphs or a dead key. `dnf install`
4861 # only proves a package name still resolves; it does not prove a metapackage still
4862 # pulls the faces it pulled last release, and that is the part that can rot
4863 # quietly across a Fedora bump.
4864 #
4865 # Ten languages rather than two, because the failure mode of a short list is that
4866 # the script left out is the one nobody here reads and therefore nobody notices.
4867 # The counts are deliberately not pinned: `-ge 1` is the claim that matters, and a
4868 # fixed number would fail on a Fedora rebuild that merely repackages a family.
4869 #
4870 # `HOME=/etc/skel` is load-bearing and not decoration. Alloy's aliases live in
4871 # etc/skel/.config/fontconfig/fonts.conf, which is per-user, so the build's root
4872 # context does not see them: measured on this image, a plain `fc-match sans-serif`
4873 # answers "Noto Sans" while the same query under the skeleton answers Alloy's own
4874 # sans. A guard written without it would have asserted the base's opinion
4875 # and passed while claiming to check Alloy's.
4876 #
4877 # Conditional since 2026-08-03, for the reason the skel-night guard above it
4878 # is: the coverage metapackages are installed in the client-only block, since
4879 # they are there for the browser and a headless box has none, while this test
4880 # was written before the split and asserts them on both. `PROFILE=server`
4881 # failed here on the first server build.
4882 #
4883 # The server branch is not a skip, and since 2026-08-19 it asserts the opposite
4884 # of what it used to. It said "Quasi Mono is missing; the font layers are
4885 # unconditional and this profile has a console", which was wrong about where a
4886 # TUI's glyphs come from: a session reached over ssh is rasterised by the
4887 # CLIENT's font stack, and the server end opens no font file at all. That
4888 # sentence was also the thing holding the payload in place, since the faces
4889 # could not be dropped while a check demanded them. So the branch now proves
4890 # the faces are ABSENT, which is the pattern every other `$PROFILE` conditional
4891 # in this file follows: client proves presence, server proves absence.
4892 #
4893 # It proves it without fontconfig, because there is none to ask. The install is
4894 # client-only now (see the fontconfig block above), so `fc-list` is not on this
4895 # profile's PATH and the queries are filesystem and rpm questions instead:
4896 # no fc-list, no /usr/share/fonts/quasi, none of the font packages the client
4897 # block installs, no package whose name says emoji.
4898 #
4899 # BOTH OF THOSE PACKAGE QUERIES ARE WEAKER THAN THE CHARSET QUERY THEY REPLACE,
4900 # and the weakness is worth stating rather than conceding on the emoji half
4901 # alone. `fc-list ':lang=ja'` answered for coverage arriving from any source,
4902 # including a face dropped into /usr/share/fonts by something that is not a
4903 # package at all. A name list answers only for the three packages the client
4904 # block installs, and a name sweep for emoji answers only for a font that says
4905 # so in its name. What makes the list a list rather than a guess is that it is
4906 # checked against the client's own install lines by
4907 # `crates/alloy/tests/font_profile.rs`, so a fourth font package added up there
4908 # fails the test rather than sliding past this branch.
4909 #
4910 # What would settle the charset half properly is installing fontconfig on
4911 # `server` to ask the charset question, and that is the payload this whole
4912 # change exists to drop, for 784 KiB of tools to answer a question about a
4913 # machine that rasterises nothing. So it is not asked here. Only that half is
4914 # out of reach, though: a face dropped into /usr/share/fonts by something that
4915 # is not a package is a directory listing away and needs no fontconfig, which
4916 # is the cheap check to reach for before wanting the tools back. The
4917 # claim these queries do support is the one that matters, that this profile
4918 # installs no font package at all, so the only faces on it are the base's own.
4919 #
4920 # `fc-match` is not asked on server either, and was not before: the prune takes
4921 # etc/skel/.config/fontconfig with the rest of the skeleton, so there are no
4922 # Alloy aliases left to resolve and the answer would be the base's opinion,
4923 # which is the exact mistake the HOME=/etc/skel note above warns about.
4924 #
4925 # THE SERVER BRANCH SPLITS ON $GUI, and the split is a measured answer to the
4926 # question the old assertion asked when it fired. It held that a server image
4927 # carries no fontconfig at all, and astra is the first machine to be both
4928 # `PROFILE=server` and `GUI=tauri`: it builds the arm64 Tauri releases, natively,
4929 # because nothing here cross-compiles. Those builds want gtk3-devel, libsoup3,
4930 # librsvg2 and webkit2gtk4.1, and that set drags in the GTK and WebKit runtime
4931 # with fontconfig underneath it. Measured 2026-09-04, at step 101 of 103, on the
4932 # first astra mint anyone had ever run.
4933 #
4934 # So the payload argument still holds where it was made and does not reach this
4935 # machine. `GUI=none` keeps the original claim, unchanged and now stated as what
4936 # it always meant. `GUI=tauri` asserts the other direction instead -- fontconfig
4937 # has to BE there, since a build host missing it fails at link time rather than
4938 # at boot -- and every other claim on this branch is kept: no house faces, no
4939 # font package from the client's list, no emoji. What a headless build host may
4940 # not have is a face. Tools that answer questions about faces are the price of
4941 # building the apps, and the apps are why the machine exists.
4942 RUN set -eux; \
4943 if [ "$PROFILE" = client ]; then \
4944 for lang in ja zh-cn zh-tw ko ar he hi th bn ta; do \
4945 [ "$(fc-list ":lang=$lang" family | wc -l)" -ge 1 ] \
4946 || { echo "no font covers '$lang'; every page in that script renders as missing glyphs" >&2; exit 1; }; \
4947 done; \
4948 HOME=/etc/skel fc-match sans-serif | grep -q 'Quasi Body' \
4949 || { echo "sans-serif is no longer Quasi Body; the added coverage outranked Alloy's own pick" >&2; exit 1; }; \
4950 HOME=/etc/skel fc-match monospace | grep -q 'Quasi Mono' \
4951 || { echo "monospace is no longer Quasi Mono; every cell grid would draw its borders from a fallback face on its own baseline" >&2; exit 1; }; \
4952 HOME=/etc/skel fc-match monospace | grep -q 'Regular' \
4953 || { echo "monospace resolves to a weight other than Regular; the face is variable and its own default instance is ExtraLight, so this is what an unnamed weight gets" >&2; exit 1; }; \
4954 [ "$(fc-list ':charset=1F600' family | wc -l)" -eq 0 ] \
4955 || { echo "an emoji font arrived; docs/STACK.md says none is shipped, so update the decision or drop the font" >&2; exit 1; }; \
4956 elif [ "$GUI" = none ]; then \
4957 ! command -v fc-list >/dev/null \
4958 || { echo "profile=server with GUI=none carries fontconfig after every install; something now requires it, so find out what and decide, rather than leaving this branch asserting something untrue" >&2; exit 1; }; \
4959 test ! -e /usr/share/fonts/quasi \
4960 || { echo "profile=server carries the house faces; nothing on it rasterises a glyph, and a TUI over ssh is drawn by the client's fonts" >&2; exit 1; }; \
4961 for pkg in default-fonts-cjk-sans default-fonts-other-sans google-noto-sans-mono-cjk-vf-fonts; do \
4962 ! rpm -q --quiet "$pkg" \
4963 || { echo "profile=server carries $pkg, one of the font packages the client block installs; it ships no browser and no terminal, so nothing on it would read the face" >&2; exit 1; }; \
4964 done; \
4965 ! rpm -qa | grep -qi emoji \
4966 || { echo "an emoji font package arrived on profile=server; docs/STACK.md says none is shipped, so update the decision or drop the font" >&2; exit 1; }; \
4967 echo "fonts: no font stack on this profile, which is what a headless machine draws with"; \
4968 else \
4969 command -v fc-list >/dev/null \
4970 || { echo "profile=server GUI=$GUI and no fontconfig; the toolkit packages that pull it in did not land, so a Tauri build here would fail" >&2; exit 1; }; \
4971 test ! -e /usr/share/fonts/quasi \
4972 || { echo "profile=server carries the house faces; nothing on it rasterises a glyph, and a TUI over ssh is drawn by the client's fonts" >&2; exit 1; }; \
4973 for pkg in default-fonts-cjk-sans default-fonts-other-sans google-noto-sans-mono-cjk-vf-fonts; do \
4974 ! rpm -q --quiet "$pkg" \
4975 || { echo "profile=server carries $pkg, one of the font packages the client block installs; it ships no browser and no terminal, so nothing on it would read the face" >&2; exit 1; }; \
4976 done; \
4977 ! rpm -qa | grep -qi emoji \
4978 || { echo "an emoji font package arrived on profile=server; docs/STACK.md says none is shipped, so update the decision or drop the font" >&2; exit 1; }; \
4979 echo "fonts: fontconfig rides in with GUI=$GUI, and no face does"; \
4980 fi
4981
4982 # =====================================================================
4983 # The build host's leavings, deleted rather than declared.
4984 # =====================================================================
4985 # Everything above declares the /var content this image is supposed to carry.
4986 # This is the other half: content that is in the image only because dnf,
4987 # semodule, ldconfig and authselect ran on the machine that built it, and that
4988 # no running machine needs.
4989 #
4990 # It is last on purpose. Anything below it that reached for dnf would put the
4991 # caches back after the sweep, and the lint that follows is the thing that
4992 # would say so.
4993 #
4994 # What each line is, and why deleting it is safe rather than merely quiet:
4995 #
4996 # /run and /tmp are tmpfs at boot, so their content is unreachable on a
4997 # running machine no matter what. What is in there is dnf's lock directory
4998 # and the intermediate .cil files semodule writes while compiling policy.
4999 #
5000 # /var/log/dnf5.log* is the BUILD HOST's package log: which packages were
5001 # installed when, from which repo URLs. It is not this machine's history and
5002 # nothing reads it, but it does ship to every install.
5003 #
5004 # /var/lib/dnf and /var/cache/libdnf5 are dnf's own state and cache. The
5005 # `countme` stamps in there are upstream's install-counting telemetry, and
5006 # the pubring copies are repo keys dnf re-fetches. MEASURED 2026-08-26: dnf
5007 # answers a query with both trees deleted and recreates what it needs.
5008 #
5009 # /var/cache/ldconfig/aux-cache and the appstream .xb catalogs are caches
5010 # with a generator behind each of them.
5011 #
5012 # /var/lib/authselect/checksum records the state of the PAM files authselect
5013 # generated, so it can notice a hand edit. MEASURED 2026-08-26: with it gone
5014 # `authselect check` still answers "Current configuration is valid" and
5015 # `authselect current` still names the profile and all four features.
5016 #
5017 # WHAT THIS DOES NOT DO is make the pulled image smaller. These files were
5018 # written in layers far above, and deleting them here writes a whiteout rather
5019 # than reclaiming the bytes. What it changes is the filesystem `bootc install`
5020 # writes to a disk, which is the one an installed machine actually carries.
5021 # Reclaiming the registry bytes would mean cleaning inside the layer that made
5022 # the mess, which is a different and much larger change to this file.
5023 #
5024 # The sweep of /run tolerates failures, and the lint below is why that is not a
5025 # hole. podman bind-mounts the host's /run/systemd/resolve/stub-resolv.conf into
5026 # the build container to give it DNS, so a plain `rm -rf /run/*` dies on a busy
5027 # mount and takes the build with it. Those mounts are not image content — they
5028 # are absent from the lint's own reading of a built image — so the right move is
5029 # to sweep what can be swept and let `bootc container lint --fatal-warnings`
5030 # below be the assertion about what is left. It reads the finished filesystem,
5031 # which is the thing being claimed about, rather than this loop's exit status.
5032 RUN set -eux; \
5033 find /run /tmp -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true; \
5034 rm -f /var/log/dnf5.log*; \
5035 rm -rf /var/lib/dnf /var/cache/libdnf5; \
5036 rm -f /var/cache/ldconfig/aux-cache; \
5037 rm -f /var/cache/swcatalog/cache/*.xb; \
5038 rm -f /var/lib/authselect/checksum; \
5039 authselect check >/dev/null \
5040 || { echo "authselect stopped validating once its checksum was removed; keep the file and declare it instead" >&2; exit 1; }; \
5041 echo "sweep: build-host caches, logs and /run content removed"
5042
5043 # =====================================================================
5044 # bootc validation — fails the build if the image isn't a valid
5045 # bootable container.
5046 # =====================================================================
5047 # --fatal-warnings since 2026-08-26. Without it this was the one validation
5048 # step in a file otherwise dense with hard assertions that could not fail the
5049 # build, and it spent an unknown number of builds reporting three warnings on
5050 # `server` and four on `client` that nothing read. That is the same shape of
5051 # problem as a sweep grid nobody opens: a detector whose output reaches no one
5052 # is not a control.
5053 #
5054 # --no-truncate because the default prints five entries per lint and then a
5055 # count, which on a failing build is the half of the list you need hidden
5056 # behind the half you have. The output is only read when it has already
5057 # stopped the build, so there is nothing to keep short.
5058 #
5059 # --skip is deliberately not used. It exists, and gating with the known
5060 # warnings suppressed was the cheaper option; it was refused because a skip
5061 # list is a second place to record which warnings are acceptable, and it goes
5062 # stale silently the moment the reason for one of them is fixed.
5063 RUN bootc container lint --fatal-warnings --no-truncate
5064