Skip to main content

max / alloy

Take the console and shop out of the image, and layer them at first boot GO d866e125 subtask 7, the flip itself. The carry shape shipped at 0e9d9c5 and sat inert, because alloy-layer-components.service is gated on /usr/bin/alloy being absent and the image still copied it in. This is what makes that gate true. All four parts land together: a subset produces an image that installs and has no console, which nobody can recover from inside the session they cannot start. The rust-build stage now packages both binaries and runs createrepo_c, and the runtime stage carries the result at /usr/share/alloy/rpm with a .repo file beside it. The two COPY lines are gone, and in their place an assertion that neither binary is present: a returning /usr/bin/alloy is the quiet failure, since the unit's gate goes false, the machine works, and the console it carries can never be replaced. build/rpm/shop.spec is new, and its Requires was measured rather than declared. ldd names the linked pair, fontconfig and libxkbcommon. Everything else shop needs is opened through dlopen and appears in no ELF header, so an automatic scan cannot see it at all: libwayland-client, libwayland-egl, libvulkan and libEGL, read out of the binary's strings. By package name rather than soname, because a soname dependency is arch-qualified and this builds on both hosts. Both GPU backends are required, not just Vulkan, because GLES through libEGL is what keeps a machine with no Vulkan driver in a working terminal. Two open decisions answered on the way, both by measurement rather than taste: - The repo ships enabled, and it is forced. `rpm-ostree install` supports --enablerepo "only in a container build", so a repo shipped disabled is one the first-boot unit could not turn on for its own transaction. The standing enabled-or-opt-in question is about the future network repo, where the answer is a real choice; this one reaches no network. - The live installer environment installs the console from the carried repo. That proves the repo works on a build host rather than at somebody's first boot, where the symptom would be a session that will not start. The live-root install is in make-iso.sh and not in build/Containerfile.iso, which is where it went first and was wrong. That builder is only the tooling container: build-iso.sh mounts the Alloy image and make-iso.sh squashes THAT into the live root, so installing into the builder reached a container that is thrown away. The ISO booted with no console at all and alloy-installer.service failed. Found by booting it, which is the reason verification here is an image build and a VM rather than a unit test; the build-time assertion passed the whole time, asserting the right thing about the wrong image. The unit reads its package list from /usr/share/alloy/components rather than naming alloy and shop itself. One unit file serves both profiles and server has no compositor, so the image writes the list and the unit installs what it is told. Both RPMs are carried on both profiles even so: dropping shop's file on server would leave metadata describing a package that is not there. Verified end to end in qemu. The live environment carries the console and reports it through rpm; the installed machine carries neither binary; layering from the carried repo with no network adds alloy-0.1.0 and shop-0.0.1 as LayeredPackages, by bare name, which is what keeps the future drain rule safe; the machine reaches greetd with both working, and the unit is inactive on the next boot because its gate is now false. build/make-iso.sh also carries Max's in-progress build timing instrumentation, committed here because the two edits are in one file.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-15 01:09 UTC
Signed with PGP, not checked
Commit: cad26d5c61d6024d245dfc5bc1e40520f4b81daf
Parent: 0e9d9c5
10 files changed, +493 insertions, -56 deletions
M .containerignore +11 -2
@@ -36,12 +36,21 @@
36 36 /output.prev
37 37 /dist
38 38
39 + # The install-source cache build/build-iso.sh keeps between runs: an OCI layout
40 + # of the image itself, so ~3G that no COPY reads and that would otherwise be
41 + # tarred into the build that produced it.
42 + /.iso-cache
43 +
39 44 # Not read by any COPY.
40 45 /.git
41 - # Excluded except the one script build/Containerfile.iso copies in. The
42 - # negation has to follow the exclusion; reversed, it does nothing.
46 + # Excluded except the few files a COPY reads: the script
47 + # build/Containerfile.iso copies in, and the two RPM specs the rust-build stage
48 + # packages the console and the terminal with. The negations have to follow the
49 + # exclusion; reversed, they do nothing.
43 50 /build
44 51 !/build/make-iso.sh
52 + !/build/rpm/alloy.spec
53 + !/build/rpm/shop.spec
45 54 /builds.disabled
46 55 /docs
47 56 /tools
M Containerfile +257 -25
@@ -61,10 +61,44 @@
61 61 # can add a build dependency, which is what happened at shop@53551eb and broke
62 62 # both profiles at this step. The runtime already carries fontconfig for the
63 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.
64 71 RUN dnf install -y cargo rust git wayland-devel libxkbcommon-devel \
65 - fontconfig-devel pkgconf \
72 + fontconfig-devel pkgconf rpm-build createrepo_c \
66 73 && dnf clean all
67 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 +
68 102 # =====================================================================
69 103 # shop, Alloy's terminal.
70 104 # =====================================================================
@@ -350,6 +384,81 @@
350 384 || { echo "etc/skel/$rel is also rendered from templates/; one of the two is dead" >&2; exit 1; }; \
351 385 done
352 386
387 + # =====================================================================
388 + # The component repo: our two binaries, packaged and never installed.
389 + # =====================================================================
390 + # The runtime stage does not copy the console and the terminal into /usr/bin.
391 + # It carries this repo instead, and alloy-layer-components.service lays both
392 + # down as rpm-ostree layers on the first boot after an install.
393 + #
394 + # The reason is the one thing measured in build/layertest that changes the
395 + # design: a component the base image carries cannot be replaced client-side.
396 + # An installed base package fails to depsolve against a layer of the same name,
397 + # `override replace` records a request that never activates, and a loose file
398 + # in /usr/bin — which is what this image shipped until 2026-08-14 — is worse
399 + # than either, because layering over an unowned file dies in checkout with
400 + # "File exists". Any of the three is a machine whose console can never be
401 + # fixed without rebuilding an ISO and writing a drive.
402 + #
403 + # Carrying the RPMs is not carrying the component, and that distinction is the
404 + # whole design. What blocks a layer is an installed package or a file at the
405 + # path being layered over; an uninstalled .rpm under /usr/share is neither. So
406 + # the packages travel with the image, which means they travel on the ISO, which
407 + # means an offline install still produces a working machine and a machine whose
408 + # owner has not consented to anything yet never reaches the network for them.
409 + #
410 + # Packaged here rather than by build/rpm/build.sh, which is the standalone
411 + # hotfix path and cannot be called from a build stage. The specs are shared, so
412 + # the two produce the same package; `build.sh --component shop --binary` is how
413 + # a hotfix is cut from the very binary an image shipped.
414 + COPY build/rpm/alloy.spec build/rpm/shop.spec /src/rpm/
415 + RUN set -eux; \
416 + : "The version comes off each binary, and the console's is cross-checked"; \
417 + : "against its crate. An RPM claiming a version the binary inside it does"; \
418 + : "not report is invisible afterwards — rpm answers one thing, --version"; \
419 + : "another, and --version is what a person quotes in a bug report."; \
420 + : ""; \
421 + : "head -1 is load-bearing, not caution. Both binaries answer --version"; \
422 + : "with a version line followed by copyright and licence lines, so an awk"; \
423 + : "over the whole output returns five words rather than one and the"; \
424 + : "comparison below fails on a version that was correct all along."; \
425 + alloy_version="$(/src/target/release/alloy --version | head -1 | awk '{print $2}')"; \
426 + crate_version="$(grep -m1 '^version' /src/crates/alloy/Cargo.toml | cut -d'"' -f2)"; \
427 + [ -n "$alloy_version" ] && [ "$alloy_version" = "$crate_version" ] \
428 + || { echo "console reports '$alloy_version', crates/alloy/Cargo.toml says '$crate_version'" >&2; exit 1; }; \
429 + : "shop is a separate repo pinned by revision, so there is no manifest in"; \
430 + : "this context to check it against. It answers --version without opening"; \
431 + : "a window, which is the only reason this can ask."; \
432 + shop_version="$(/shop/bin/shop --version | head -1 | awk '{print $2}')"; \
433 + [ -n "$shop_version" ] \
434 + || { echo "shop did not report a version; SHOP_REV may predate it" >&2; exit 1; }; \
435 + mkdir -p /rpmbuild/SOURCES /staged-rpm; \
436 + install -m 0755 /src/target/release/alloy /rpmbuild/SOURCES/alloy; \
437 + install -m 0755 /shop/bin/shop /rpmbuild/SOURCES/shop; \
438 + rpmbuild --define '_topdir /rpmbuild' \
439 + --define "alloy_version $alloy_version" -bb /src/rpm/alloy.spec; \
440 + rpmbuild --define '_topdir /rpmbuild' \
441 + --define "shop_version $shop_version" -bb /src/rpm/shop.spec; \
442 + cp /rpmbuild/RPMS/*/*.rpm /staged-rpm/; \
443 + createrepo_c /staged-rpm; \
444 + : "Exactly one package each, and the metadata that makes them a repo. A"; \
445 + : "repo missing one is an install that reaches the greeter with no console"; \
446 + : "or no terminal behind it. Globbed rather than named: the %{dist} tag and"; \
447 + : "the arch come from whichever host is building, and neither is this"; \
448 + : "assertion's business — one file, carrying the version asked for, is."; \
449 + set -- /staged-rpm/alloy-*.rpm; \
450 + [ "$#" -eq 1 ] && [ -f "$1" ] \
451 + || { echo "expected one console RPM, got: $*" >&2; exit 1; }; \
452 + case "$1" in */alloy-"$alloy_version"-*) ;; \
453 + *) echo "console RPM $1 is not version $alloy_version" >&2; exit 1;; esac; \
454 + set -- /staged-rpm/shop-*.rpm; \
455 + [ "$#" -eq 1 ] && [ -f "$1" ] \
456 + || { echo "expected one terminal RPM, got: $*" >&2; exit 1; }; \
457 + case "$1" in */shop-"$shop_version"-*) ;; \
458 + *) echo "terminal RPM $1 is not version $shop_version" >&2; exit 1;; esac; \
459 + [ -f /staged-rpm/repodata/repomd.xml ] \
460 + || { echo "createrepo_c wrote no metadata; the carried repo would resolve nothing" >&2; exit 1; }
461 +
353 462 # =====================================================================
354 463 # Runtime image — the bootable container itself.
355 464 # =====================================================================
@@ -422,6 +531,33 @@
422 531 # the gate lives here so it holds even when the TUI is bypassed.
423 532 ARG LANGS=rust
424 533
534 + # TRIM is the one builder choice about the *base* rather than about Alloy.
535 + # fedora-bootc is a general-purpose server base, and three of the things it
536 + # carries are unreachable from any Alloy install however the machine is used:
537 + # python3-botocore (an AWS SDK, with boto3 and s3transfer behind it),
538 + # qemu-user-static for eighteen foreign architectures, and toolbox, whose job
539 + # distrobox already does here. Measured 2026-08-15 against this image: 297 MiB
540 + # across 22 packages, and nothing else in the image requires any of them.
541 + #
542 + # It is a build-speed choice as much as a size one. Everything downstream of
543 + # the image is proportional to its size — mkfs and the squashfs on the way to
544 + # an ISO, the dd to a stick, the install onto a disk — so the base's dead
545 + # weight is paid four times by whoever builds and installs.
546 + #
547 + # WHAT THIS NEVER TOUCHES IS FIRMWARE, and that is a decision rather than an
548 + # oversight. `nvidia-gpu-firmware` alone is 101 MiB and is the largest single
549 + # thing a naive trim would take; astra needs it, and a medium built here has
550 + # to boot hardware nobody asked about at build time. The assertion below holds
551 + # firmware present on both branches so a future edit to this list cannot
552 + # quietly reach it.
553 + #
554 + # The accepted cost of `unused` is binfmt emulation: no `podman run` of a
555 + # foreign-architecture container. Every rule in ~/Code/CLAUDE.md points that
556 + # way already (builds are native per architecture, nothing is cross-compiled),
557 + # so this removes a capability the house style forbids using. `keep` is there
558 + # for whoever disagrees on their own machine.
559 + ARG TRIM=unused
560 +
425 561 # Unconditional, and first. Everything downstream trusts that these are
426 562 # words from known sets, so this is the assertion the other assertions
427 563 # stand on. `PROFILE=cleint` has to die here rather than silently build a
@@ -442,11 +578,15 @@
442 578 *) echo "unknown language '$lang'; the builder offers rust, go, python and zig" >&2; exit 1 ;; \
443 579 esac; \
444 580 done; \
581 + case "$TRIM" in \
582 + unused|keep) ;; \
583 + *) echo "unknown TRIM '$TRIM'; expected 'unused' or 'keep'" >&2; exit 1 ;; \
584 + esac; \
445 585 case "$PROFILE:$BROWSER" in \
446 586 client:*|server:none) ;; \
447 587 server:*) echo "PROFILE=server ships no graphical session and cannot carry BROWSER=$BROWSER" >&2; exit 1 ;; \
448 588 esac; \
449 - echo "building profile=$PROFILE browser=$BROWSER langs=$LANGS"
589 + echo "building profile=$PROFILE browser=$BROWSER langs=$LANGS trim=$TRIM"
450 590
451 591 # =====================================================================
452 592 # Third-party repos
@@ -1208,6 +1348,36 @@
1208 1348 foot \
1209 1349 || true
1210 1350
1351 + # The base's own dead weight, under TRIM. The list and its cost are argued at
1352 + # `ARG TRIM` above; this is only the removal.
1353 + #
1354 + # Both branches assert, the same way the PROFILE conditionals do. `unused`
1355 + # proves the packages are really gone rather than trusting a glob that may
1356 + # have matched nothing, and refuses to pass silently if it found none of them
1357 + # to remove: an empty match means the base composition moved and this list is
1358 + # describing a machine that no longer exists, which is the state a comment
1359 + # would sit in for a year. Firmware is checked on BOTH branches, because the
1360 + # thing that must stay true is that no trim ever reaches it.
1361 + RUN set -eu; \
1362 + if [ "$TRIM" = unused ]; then \
1363 + list="python3-botocore toolbox qemu-user-static*"; \
1364 + gone="$(rpm -qa --qf '%{NAME}\n' $list)"; \
1365 + [ -n "$gone" ] \
1366 + || { 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; }; \
1367 + dnf remove -y $gone; \
1368 + for pkg in python3-botocore toolbox; do \
1369 + ! rpm -q "$pkg" >/dev/null 2>&1 \
1370 + || { echo "$pkg survived the trim" >&2; exit 1; }; \
1371 + done; \
1372 + echo "trim: removed $(echo "$gone" | wc -l) base packages nothing in Alloy reaches"; \
1373 + else \
1374 + echo "trim: none, the base ships as it comes"; \
1375 + fi; \
1376 + for fw in linux-firmware nvidia-gpu-firmware; do \
1377 + rpm -q "$fw" >/dev/null 2>&1 \
1378 + || { 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; }; \
1379 + done
1380 +
1211 1381 # =====================================================================
1212 1382 # System user for greetd. greetd drops privileges to this account
1213 1383 # before spawning tuigreet; without it greetd exits with
@@ -1961,6 +2131,7 @@
1961 2131 echo "profile = \"$PROFILE\""; \
1962 2132 echo "browser = \"$BROWSER\""; \
1963 2133 echo "langs = $langs"; \
2134 + echo "trim = \"$TRIM\""; \
1964 2135 echo "hostname = \"$ALLOY_HOSTNAME\""; \
1965 2136 echo "pubkey = \"\""; \
1966 2137 } > /usr/lib/alloy/build.toml; \
@@ -2065,40 +2236,101 @@
2065 2236 RUN echo 43 > /etc/dnf/vars/releasever
2066 2237
2067 2238 # =====================================================================
2068 - # The Alloy console.
2239 + # The Alloy console and terminal, as carried packages.
2069 2240 # =====================================================================
2241 + # Neither binary is in this image. Until 2026-08-14 both were COPYed into
2242 + # /usr/bin, and that is the one shape in which a component can never be
2243 + # fixed on a machine somebody already installed: a layer over an unowned
2244 + # file does not fail to depsolve, it dies in checkout with "File exists".
2245 + # The rust-build stage's "component repo" block above carries the reasoning
2246 + # and build/layertest holds the measurements.
2247 + #
2248 + # So the image carries the packages and installs neither.
2249 + # alloy-layer-components.service lays them down on the first boot after an
2250 + # install, from this repo, with no network. That unit has shipped inert
2251 + # since alloy@0e9d9c5, gated on the repo being present and /usr/bin/alloy
2252 + # being absent; this is the commit that makes the second condition true.
2253 + #
2070 2254 # Last before the lint on purpose, and later than the config tree the
2071 2255 # header calls the most-changed layer — the console changes with every
2072 2256 # commit to crates/, which is more often still. Nothing below it means a
2073 2257 # console-only rebuild reuses every package and config layer above.
2258 + COPY --from=rust-build /staged-rpm /usr/share/alloy/rpm
2259 +
2260 + # enabled=1, and it is forced rather than chosen. `rpm-ostree install` on a
2261 + # booted system talks to rpm-ostreed over D-Bus, and its --enablerepo is
2262 + # "only supported in a container build" — so a repo shipped disabled is a
2263 + # repo the first-boot unit cannot turn on for its own transaction, and the
2264 + # machine comes up with no console. The standing "repo enabled by default
2265 + # or opt-in" question (GO d866e125) is about the future NETWORK repo, where
2266 + # the answer is a real choice; this one reaches no network and answers to
2267 + # nothing but the local filesystem.
2074 2268 #
2075 - # /usr/bin rather than /usr/local/bin: /usr/local is not part of a bootc
2076 - # image's immutable tree, and the console is shipped software, not
2077 - # something the operator dropped in.
2078 - COPY --from=rust-build /src/target/release/alloy /usr/bin/alloy
2269 + # gpgcheck=0 is a marker for signing being unfinished (GO d866e125 subtask
2270 + # 4), not a decision. It is a weaker exposure than an unsigned network repo
2271 + # — these files came in with the image and are only as trustworthy as it is
2272 + # — but "the image is the trust boundary" is a claim signing should make
2273 + # explicitly rather than one this file should assume.
2274 + RUN printf '[alloy-local]\nname=Alloy components, carried with the image\nbaseurl=file:///usr/share/alloy/rpm\nenabled=1\ngpgcheck=0\n' \
2275 + > /etc/yum.repos.d/alloy-local.repo
2079 2276
2080 - # The terminal, from the same stage. /usr/bin for the same reason as the
2081 - # console: shipped software, and /usr/local is not part of a bootc image's
2082 - # immutable tree.
2083 - COPY --from=rust-build /shop/bin/shop /usr/bin/shop
2084 -
2085 - # shop is a Wayland client, so it has nothing to connect to on `server` and
2086 - # is removed there. It is copied first and pruned second because COPY takes
2087 - # no condition, and the build-stage assertion that shop installed at all
2088 - # stays unconditional in the rust-build stage above, where it belongs: the
2089 - # terminal failing to build is a bug on both profiles.
2277 + # What the first-boot unit layers, as a list the image writes rather than a
2278 + # package set the unit hardcodes. That is what makes the profile split work
2279 + # at all: `server` has no compositor, so a Wayland terminal has nothing to
2280 + # connect to and must not be laid down, and the unit is one static file
2281 + # shared by both profiles. Writing the list here keeps the decision where
2282 + # every other profile decision in this file already lives.
2283 + #
2284 + # Both RPMs are carried on both profiles even so. Dropping shop's file on
2285 + # `server` would leave the repo metadata describing a package that is not
2286 + # there, which is a landmine for anyone who later resolves against it, and
2287 + # regenerating metadata would want createrepo_c in the runtime image for
2288 + # the sake of deleting three megabytes. A server operator who wants the
2289 + # terminal can install it; nothing lays it down for them.
2090 2290 RUN if [ "$PROFILE" = client ]; then \
2091 - test -x /usr/bin/shop \
2092 - || { echo "shop did not land; the session would have no terminal" >&2; exit 1; }; \
2291 + printf 'alloy\nshop\n' > /usr/share/alloy/components; \
2292 + grep -qx shop /usr/share/alloy/components \
2293 + || { echo "profile=client would come up with no terminal" >&2; exit 1; }; \
2093 2294 else \
2094 - rm -f /usr/bin/shop; \
2095 - test ! -e /usr/bin/shop \
2096 - || { echo "profile=server still carries the terminal" >&2; exit 1; }; \
2295 + printf 'alloy\n' > /usr/share/alloy/components; \
2296 + ! grep -qx shop /usr/share/alloy/components \
2297 + || { echo "profile=server would layer a Wayland terminal on a machine with no compositor" >&2; exit 1; }; \
2097 2298 fi
2098 2299
2099 - # The themes that binary refuses to run without. Kept next to it so the
2100 - # two move together: shipping the console without these is not a degraded
2101 - # console, it is one that exits on launch.
2300 + # The carried repo is complete, and neither binary leaked into the image.
2301 + #
2302 + # Unconditional, and both halves matter. A repo that lost its metadata
2303 + # resolves nothing, and the unit's failure mode is a machine that boots to a
2304 + # greeter, opens a session with no console and no terminal, and offers no way
2305 + # to fix either from inside it. A /usr/bin/alloy that came back — a restored
2306 + # COPY, a `dnf install` somewhere above — is quieter and worse: the unit's
2307 + # second condition goes false, it never runs, the machine works, and the
2308 + # console it carries can never be replaced. That is the whole failure this
2309 + # design exists to prevent, and it would ship looking healthy.
2310 + RUN set -eux; \
2311 + test -f /usr/share/alloy/rpm/repodata/repomd.xml \
2312 + || { echo "the carried repo has no metadata; first boot would layer nothing" >&2; exit 1; }; \
2313 + test -n "$(ls /usr/share/alloy/rpm/*.rpm)" \
2314 + || { echo "the carried repo holds no packages" >&2; exit 1; }; \
2315 + test ! -e /usr/bin/alloy \
2316 + || { echo "the image carries /usr/bin/alloy; alloy-layer-components.service will never fire and the console can never be fixed" >&2; exit 1; }; \
2317 + test ! -e /usr/bin/shop \
2318 + || { echo "the image carries /usr/bin/shop; an unowned file cannot be layered over" >&2; exit 1; }
2319 +
2320 + # The themes the console refuses to run without: with no theme file on any
2321 + # search path it does not fall back, it exits.
2322 + #
2323 + # These stay in the base image while the binary that reads them travels as a
2324 + # package, which is a deliberate split rather than an oversight. They are data
2325 + # the whole image shares — the rendered skeleton above came out of the same two
2326 + # files — so packaging them with the console would put one copy under rpm and
2327 + # another under ostree and let them disagree.
2328 + #
2329 + # What it costs, and it is worth knowing before writing a hotfix: a console fix
2330 + # that needs a NEW theme token cannot ship through the RPM channel, because the
2331 + # token would have to arrive in the base. That makes it an image change, which
2332 + # is a release rather than a hotfix (wiki `hotfix-policy`), so the constraint
2333 + # and the policy already agree.
2102 2334 COPY --from=rust-build /staged-themes /usr/share/alloy/themes
2103 2335
2104 2336 # The schemas the settings view's Applications tab is built from. Straight
@@ -22,7 +22,25 @@
22 22 WORK=/work
23 23 VOLID="ALLOY"
24 24
25 - say() { printf '==> %s\n' "$*"; }
25 + # Every phase line carries the seconds since the last one, because the ISO is
26 + # where this build spends most of its wall clock and nobody could say on what.
27 + # The four minutes were assumed to be compression until they were measured;
28 + # they were not (mksquashfs at the fast level is 8s of it). A build that
29 + # reports its own shape is the difference between optimizing the slow part and
30 + # optimizing the part that looks slow.
31 + #
32 + # The number on a line is the time spent getting TO it, not the time the phase
33 + # it announces will take, because a phase cannot report its own cost before
34 + # doing the work. Read a line's figure as the price of the line above it.
35 + START_MARK=$(date +%s)
36 + LAST_MARK=$START_MARK
37 + say() {
38 + local now elapsed
39 + now=$(date +%s)
40 + elapsed=$((now - LAST_MARK))
41 + LAST_MARK=$now
42 + printf '==> [+%3ds] %s\n' "$elapsed" "$*"
43 + }
26 44
27 45 [ -d "$ROOTFS" ] || { echo "no $ROOTFS" >&2; exit 1; }
28 46 [ -d "$OUTPUT" ] || { echo "no $OUTPUT" >&2; exit 1; }
@@ -148,9 +166,67 @@
148 166 truncate -s "${IMG_MB}M" "$WORK/sqroot/LiveOS/rootfs.img"
149 167 mkfs.ext4 -q -L Alloy -N "$INODES" -d "$ROOTFS" "$WORK/sqroot/LiveOS/rootfs.img"
150 168
151 - # zstd level 19 costs minutes of saturated CPU for a few percent of size,
152 - # which is worth it for a release and pure waste when the question is
153 - # whether the thing boots at all. ALLOY_ISO_FAST picks the cheap level.
169 + # The console, into the live root and nowhere else.
170 + #
171 + # The image deliberately does not carry /usr/bin/alloy. A component the base
172 + # carries can never be replaced client-side, so the console and shop travel as
173 + # uninstalled RPMs at /usr/share/alloy/rpm and are layered on the first boot
174 + # after an install (Containerfile, "The component repo"; measurements in
175 + # build/layertest).
176 + #
177 + # The live environment is the one place that cannot wait for a first boot. It
178 + # never gets one, it is thrown away when the install finishes, and `alloy
179 + # install` is the entire reason the medium exists: alloy-installer.service and
180 + # the installer account's ForceCommand both name /usr/bin/alloy. Without this
181 + # the ISO boots to a machine with no installer, which is how it behaved for
182 + # exactly one build.
183 + #
184 + # It goes into the mounted image rather than into $ROOTFS, which is the
185 + # read-only mount of the shipped image and must stay exactly what installs.
186 + # `bootc install` reads the OCI layout under /source, not this tree, so the
187 + # console lands in the live session and reaches no installed machine.
188 + #
189 + # From the carried repo rather than by copying a binary in, and the difference
190 + # is what it proves: this is the first thing that ever resolves against
191 + # /usr/share/alloy/rpm, so a package that cannot install fails here, on a build
192 + # host, instead of at somebody's first boot where the symptom is a session that
193 + # will not start. rpm rather than dnf because the repo is on the read-only tree
194 + # and the dependencies are already in the base, so nothing needs resolving.
195 + say "installing the console into the live root"
196 + mkdir -p "$WORK/liveroot"
197 + mount -o loop "$WORK/sqroot/LiveOS/rootfs.img" "$WORK/liveroot"
198 + set -- "$ROOTFS"/usr/share/alloy/rpm/alloy-*.rpm
199 + [ "$#" -eq 1 ] && [ -f "$1" ] \
200 + || { echo "expected one console RPM in the carried repo, got: $*" >&2; umount "$WORK/liveroot"; exit 1; }
201 + rpm --root "$WORK/liveroot" -i "$1" \
202 + || { echo "the console RPM did not install into the live root" >&2; umount "$WORK/liveroot"; exit 1; }
203 + test -x "$WORK/liveroot/usr/bin/alloy" \
204 + || { echo "no /usr/bin/alloy in the live root; the ISO would boot with no installer" >&2; umount "$WORK/liveroot"; exit 1; }
205 + umount "$WORK/liveroot"
206 + rmdir "$WORK/liveroot"
207 +
208 + # ALLOY_ISO_FAST picks the cheap level, for when the question is whether the
209 + # thing boots at all rather than how big it is.
210 + #
211 + # Measured on fw13 (12 cores) against this image, 2026-08-15, 6.4G of rootfs:
212 + #
213 + # level time squashfs.img
214 + # 3 8s 2.519 G
215 + # 9 20s 2.412 G
216 + # 12 29s 2.405 G
217 + # 15 63s 2.398 G
218 + # 19 192s 2.264 G
219 + #
220 + # Two things in that table, and both contradict what this comment used to say.
221 + # It claimed level 19 costs "about ten minutes of saturated CPU for a few
222 + # percent of size"; it costs three, and it is the only level that buys anything
223 + # after 9 — the jump at 19 is zstd's long-distance matching, which the levels
224 + # below it do not enable. And 12 and 15 are dominated: 15 spends 43 seconds
225 + # more than 9 to save 14 MB.
226 + #
227 + # So the two levels here are the two worth having. 19 for a release, where
228 + # three minutes is nothing against 255 MB off every stick and every install.
229 + # 3 for iteration, where eight seconds is close enough to free.
154 230 if [ "${ALLOY_ISO_FAST:-0}" = "1" ]; then
155 231 COMP_LEVEL=3
156 232 say "compressing squashfs.img (fast mode, level $COMP_LEVEL)"
@@ -558,3 +634,6 @@
558 634
559 635 chmod 0644 "$OUTPUT/install.iso"
560 636 say "built $(du -h "$OUTPUT/install.iso" | cut -f1) at $OUTPUT/install.iso"
637 +
638 + TOTAL=$(( $(date +%s) - START_MARK ))
639 + printf '==> total %dm%02ds\n' "$((TOTAL / 60))" "$((TOTAL % 60))"
@@ -40,6 +40,10 @@
40 40
41 41 **A hotfixable component cannot be in the base image.** This is measured, not chosen. A component the base carries cannot be replaced client-side: `rpm-ostree install` fails to depsolve against it, and `rpm-ostree override replace` records a request that never activates and survives a reboot as a silent no-op, so the machine reports a hotfix it is not running. A component that is a loose file owned by no package is worse still, and cannot be layered over at all. So the console and shop are layered at install time rather than built into the image. Measurements and how to re-run them: `build/layertest/README.md`.
42 42
43 + **They ride inside the image as packages, and the first boot lays them down.** The image carries `alloy` and `shop` as RPM files in a `file://` repo at `/usr/share/alloy/rpm` and installs neither. Carrying the RPMs is not carrying the component: what blocks a layer is an installed package or a file at the path being layered over, and an uninstalled `.rpm` under `/usr/share` is neither. Because the ISO is the image, the packages travel on the medium, so an offline install produces a working machine and a machine whose owner has not consented to anything yet never reaches the network to become usable. `alloy-layer-components.service` installs what `/usr/share/alloy/components` names on the first boot after an install and reboots once; `/usr` is a read-only ostree tree, so there is no version of this that ends with the console usable in the session it ran from. The live installer environment is the exception and installs the console from the same repo, since it never gets a first boot and `alloy install` is the reason it exists.
44 +
45 + That repo is enabled, and it is forced rather than chosen: `rpm-ostree install` on a booted system supports `--enablerepo` only in a container build, so a repo shipped disabled is one the first-boot unit could not turn on for its own transaction. It reaches no network, which is why this does not touch the position below.
46 +
43 47 **Our layers are disposable, and `alloy update` is what makes them so.** A layered package whose version the base later overtakes wedges the machine: every subsequent update fails to depsolve, permanently, and that is the normal life of a hotfix rather than an edge case. The rule that avoids it is to carry no layer of ours across an upgrade. Drop them, upgrade, re-apply only if the base still lacks the fix. It costs one reboot rather than two, because the two transactions compose into one deployment, and it hands `bootc upgrade` back a deployment it will consent to work on.
44 48
45 49 Dropping them has to be precise. A request is recorded under the string that was typed to install it, so a package layered by full name-version-release cannot be removed by its bare name, and rpm-ostree reports that as nothing-to-do rather than as a failure. `alloy update` reads `requested-packages` and removes exactly what it finds. Not `rpm-ostree reset`, which would also drop packages the user layered themselves, and those are not ours to remove.
@@ -10,7 +10,7 @@
10 10 # rpm the component is an RPM installed at build time, so it is a base
11 11 # package. This is what an RPM channel would want.
12 12 # loose the component is a file copied into /usr/bin owned by no package.
13 - # This is what Alloy does today (Containerfile:2067 and :2072).
13 + # What Alloy did until 2026-08-14, and what this measurement ended.
14 14 # none the image does not carry the component at all, so a hotfix is a
15 15 # plain layered package rather than a replacement.
16 16 # carry the image carries the component's RPM as a FILE, in a file:// repo
@@ -46,9 +46,10 @@
46 46 Reproduce with variant `r1`.
47 47
48 48 **A component that is an unowned file cannot be layered over at all.** This is
49 - the shape Alloy actually ships: `Containerfile:2067` and `:2072` copy the
50 - console and shop into `/usr/bin`, owned by no package. Layering an RPM that
51 - carries the same path dies in checkout rather than in depsolve:
49 + the shape Alloy shipped until 2026-08-14: two `COPY` lines put the console and
50 + shop into `/usr/bin`, owned by no package. Both are gone, and this measurement
51 + is why. Layering an RPM that carries the same path dies in checkout rather than
52 + in depsolve:
52 53
53 54 error: Checkout alloy-demo-0.0.2: Hardlinking ... to alloy-demo: File exists
54 55
M build/rpm/build.sh +52 -19
@@ -1,10 +1,19 @@
1 1 #!/usr/bin/env bash
2 2 #
3 - # build.sh — package the console as an RPM, and put it in a repo.
3 + # build.sh — package a component as an RPM, and put it in a repo.
4 4 #
5 - # build.sh build the console from the tree, package it
6 - # build.sh --binary PATH package a binary somebody else already built
7 - # build.sh --out DIR write somewhere other than build/rpm/out
5 + # build.sh build the console from the tree, package it
6 + # build.sh --binary PATH package a binary somebody else already built
7 + # build.sh --component shop \
8 + # --binary PATH package the terminal
9 + # build.sh --out DIR write somewhere other than build/rpm/out
10 + #
11 + # The console is the default because it is the one component this repo can
12 + # build. shop lives in its own repo, so there is nothing here to compile and
13 + # --binary is the only way to package it — normally the binary the image
14 + # shipped, taken out of a build with `podman cp`, so the hotfix is byte-for-byte
15 + # what was tested. The image builds both itself, from these same specs, in the
16 + # Containerfile's rust-build stage.
8 17 #
9 18 # This is the supply side of the hotfix channel (GoingsOn task d866e125). Alloy
10 19 # is distributed as a builder rather than as an image, so a machine that is
@@ -36,22 +45,42 @@
36 45 say() { printf '%s\n' "$*"; }
37 46
38 47 BINARY=""
48 + COMPONENT="alloy"
39 49 OUT="$HERE/out"
40 50 while [ $# -gt 0 ]; do
41 51 case "$1" in
42 - --binary) BINARY="${2:-}"; shift 2 ;;
43 - --out) OUT="${2:-}"; shift 2 ;;
52 + --binary) BINARY="${2:-}"; shift 2 ;;
53 + --component) COMPONENT="${2:-}"; shift 2 ;;
54 + --out) OUT="${2:-}"; shift 2 ;;
44 55 *) die "unknown argument $1" ;;
45 56 esac
46 57 done
47 58
48 - # The version comes from the crate and from nowhere else. A packaging script
49 - # that took its own --version argument is how an RPM ends up claiming a version
50 - # the binary inside it does not report, and `alloy --version` is what a person
51 - # quotes in a bug report.
52 - VERSION="$(grep -m1 '^version' "$REPO_ROOT/crates/alloy/Cargo.toml" | cut -d'"' -f2)"
53 - [ -n "$VERSION" ] || die "no version in crates/alloy/Cargo.toml"
54 - [ "$VERSION" != "0.0.0" ] || die "the console is still at 0.0.0; see GO 8c5e2838"
59 + case "$COMPONENT" in
60 + alloy|shop) ;;
61 + *) die "unknown component $COMPONENT (alloy, shop)" ;;
62 + esac
63 + [ "$COMPONENT" != shop ] || [ -n "$BINARY" ] \
64 + || die "shop is a separate repo; package it with --binary PATH"
65 +
66 + # Where the version comes from, and it is never an argument to this script. An
67 + # RPM that claims a version the binary inside it does not report is invisible
68 + # afterwards: it installs, rpm answers one thing, `--version` answers another,
69 + # and `--version` is what a person quotes in a bug report.
70 + #
71 + # The console has a crate here to read, so that is the source and the binary is
72 + # checked against it below. shop does not, so its own `--version` is the only
73 + # statement of record — which is why it is asked before anything else happens
74 + # rather than trusted after packaging.
75 + if [ "$COMPONENT" = alloy ]; then
76 + VERSION="$(grep -m1 '^version' "$REPO_ROOT/crates/alloy/Cargo.toml" | cut -d'"' -f2)"
77 + [ -n "$VERSION" ] || die "no version in crates/alloy/Cargo.toml"
78 + [ "$VERSION" != "0.0.0" ] || die "the console is still at 0.0.0; see GO 8c5e2838"
79 + else
80 + [ -x "$BINARY" ] || die "no shop binary at $BINARY"
81 + VERSION="$("$BINARY" --version 2>/dev/null | head -1 | awk '{print $2}')"
82 + [ -n "$VERSION" ] || die "$BINARY did not report a version"
83 + fi
55 84
56 85 if [ -z "$BINARY" ]; then
57 86 say "building the console $VERSION"
@@ -69,27 +98,31 @@
69 98 (cd "$REPO_ROOT" && cargo build --release -p alloy)
70 99 BINARY="$REPO_ROOT/target/release/alloy"
71 100 fi
72 - [ -x "$BINARY" ] || die "no console binary at $BINARY"
101 + [ -x "$BINARY" ] || die "no $COMPONENT binary at $BINARY"
73 102
74 103 # Checked rather than trusted. Packaging a binary whose version disagrees with
75 104 # the spec's is the one mistake this script exists to make impossible, and it
76 105 # is invisible afterwards: the RPM installs, the machine reports the version
77 106 # rpm knows, and the binary reports another.
107 + #
108 + # For shop the two came from the same place a moment ago, so this is a re-read
109 + # rather than a comparison. Left in the same path anyway: --binary can point at
110 + # one file and the version have been taken from another only if this changes.
78 111 REPORTED="$("$BINARY" --version 2>/dev/null | head -1 | awk '{print $2}')"
79 112 [ "$REPORTED" = "$VERSION" ] \
80 - || die "binary reports $REPORTED, Cargo.toml says $VERSION"
113 + || die "binary reports $REPORTED, expected $VERSION"
81 114
82 115 mkdir -p "$OUT"
83 116 rm -rf "${OUT:?}/rpmbuild" "${OUT:?}/repo"
84 117 mkdir -p "$OUT/rpmbuild/SOURCES" "$OUT/repo"
85 - install -m 0755 "$BINARY" "$OUT/rpmbuild/SOURCES/alloy"
118 + install -m 0755 "$BINARY" "$OUT/rpmbuild/SOURCES/$COMPONENT"
86 119
87 120 # In a container rather than on the host: rpmbuild and createrepo_c are not on
88 121 # a dev box by default, and the package has to carry the same %{dist} as the
89 122 # base it installs into. fedora:43 matches the image; a package built as .fc42
90 123 # lands in a repo the machine will resolve and then refuses to be what anyone
91 124 # asked for.
92 - say "packaging alloy-$VERSION"
125 + say "packaging $COMPONENT-$VERSION"
93 126 privc podman run --rm \
94 127 -v "$HERE:/spec:ro,z" \
95 128 -v "$OUT:/out:z" \
@@ -97,8 +130,8 @@
97 130 registry.fedoraproject.org/fedora:43 bash -c "
98 131 set -e
99 132 dnf -y install rpm-build createrepo_c >/dev/null 2>&1
100 - rpmbuild --define '_topdir /out/rpmbuild' --define 'alloy_version $VERSION' \
101 - -bb /spec/alloy.spec >/dev/null
133 + rpmbuild --define '_topdir /out/rpmbuild' --define '${COMPONENT}_version $VERSION' \
134 + -bb /spec/$COMPONENT.spec >/dev/null
102 135 cp /out/rpmbuild/RPMS/*/*.rpm /out/repo/
103 136 createrepo_c /out/repo >/dev/null
104 137 " >/dev/null
@@ -37,7 +37,8 @@
37 37 # other direction, which is why the condition lives in the unit and not here.
38 38 enable alloy-installer-ssh.service
39 39 # Lays the console and shop down as layered packages on a freshly installed
40 - # machine, from the repo the installer copied off the medium. They cannot ship
40 + # machine, from the repo that rides inside the image at /usr/share/alloy/rpm,
41 + # so it needs no network and nothing copied off the medium. They cannot ship
41 42 # inside the image: a component the base carries can never be replaced
42 43 # client-side, so an image carrying them is an image whose console can never be
43 44 # fixed. See alloy-layer-components.service and build/layertest.
@@ -83,7 +83,15 @@
83 83 # -y because rpm-ostree prompts on a tty it does not have, and a unit blocked
84 84 # forever on a prompt nobody can see is indistinguishable from a hang.
85 85 # --idempotent so a re-run after a partial failure is not itself an error.
86 - ExecStart=/usr/bin/rpm-ostree install --idempotent -y alloy shop
86 + #
87 + # The package list is read from the image rather than written here. One unit
88 + # file serves both profiles, and `server` has no compositor, so laying a Wayland
89 + # terminal down there would install something that cannot run. The image knows
90 + # which profile it is and writes the list at build time; this unit installs what
91 + # it is told. A missing or empty list leaves rpm-ostree with no arguments, which
92 + # fails loudly — the right outcome, since the alternative is a machine that
93 + # quietly comes up with no console.
94 + ExecStart=/bin/sh -c 'exec /usr/bin/rpm-ostree install --idempotent -y $(cat /usr/share/alloy/components)'
87 95 ExecStart=/usr/bin/systemctl reboot
88 96
89 97 # A failure here leaves a machine with no console, which the user cannot fix
@@ -1,0 +1,70 @@
1 + %global debug_package %{nil}
2 +
3 + Name: shop
4 + Version: %{shop_version}
5 + Release: 1%{?dist}
6 + Summary: Alloy's terminal
7 + License: MIT
8 + URL: https://makenot.work/git/max/shop
9 +
10 + # Handed in through the buildroot rather than compiled here, same as the
11 + # console's spec and for the same reason: the Containerfile's rust-build stage
12 + # is the single definition of how shop is built, holding the revision pin, the
13 + # `--locked` graph and the -devel packages it links against. A %%build here
14 + # would be a second definition, and the two would drift toward whichever one
15 + # ran last. See build/rpm/alloy.spec for the longer version.
16 + Source0: shop
17 +
18 + # Explicit, because the automatic scan is wrong in both directions here.
19 + #
20 + # Too strict on what it sees: left on, rpm requires the exact soname set of
21 + # whatever host built the binary, which is a package that refuses to install
22 + # across a Fedora minor bump.
23 + #
24 + # And blind to most of what shop actually needs. Only fontconfig and
25 + # libxkbcommon are linked; the Wayland and GPU libraries are opened at runtime
26 + # through dlopen, so they appear in no ELF header and a scan cannot see them at
27 + # all. Measured on the built binary rather than read off the manifest: `ldd`
28 + # names libfontconfig.so.1 and libxkbcommon.so.0, and the dlopen strings name
29 + # libwayland-client.so.0, libwayland-egl.so.1, libvulkan.so.1 and libEGL.so.1.
30 + #
31 + # By package name rather than by soname, because a soname dependency is
32 + # arch-qualified — `libwayland-client.so.0()(64bit)` — and this spec is built
33 + # on both x86_64 and aarch64 hosts. The package names are the same on both.
34 + #
35 + # Both GPU backends are required, not just Vulkan. wgpu is configured with
36 + # `vulkan` and `gles` together on purpose (Containerfile, SHOP_REV region): GLES
37 + # through libEGL is what keeps a machine with no Vulkan driver in a working
38 + # terminal, and a terminal is the one component whose absence has no recovery
39 + # path from inside the session. libwayland-egl is that path's other half.
40 + AutoReqProv: no
41 + Requires: glibc
42 + Requires: libgcc
43 + Requires: fontconfig
44 + Requires: libxkbcommon
45 + Requires: libwayland-client
46 + Requires: libwayland-egl
47 + Requires: vulkan-loader
48 + Requires: libglvnd-egl
49 +
50 + %description
51 + shop, Alloy's terminal: Wayland-only, GPU-accelerated, and the binary sway
52 + binds $mod+Return to.
53 +
54 + Packaged so a fix can reach an installed machine without rebuilding an ISO and
55 + writing a drive (GoingsOn task d866e125).
56 +
57 + This package must never be part of a base image. A component the base carries
58 + cannot be replaced client-side, and a file copied into /usr/bin owned by no
59 + package is the worst shape of all: layering over it does not fail to depsolve,
60 + it dies in checkout with "File exists". shop is layered at install time and it
61 + stays layered. Measured in build/layertest.
62 +
63 + %install
64 + mkdir -p %{buildroot}%{_bindir}
65 + install -m 0755 %{SOURCE0} %{buildroot}%{_bindir}/shop
66 +
67 + %files
68 + %{_bindir}/shop
69 +
70 + %changelog