Skip to main content

max / alloy

LANGS defaults to nothing, so a stock image carries no compiler An image's default toolchains are what the image technically requires to function. Measured, that is none: the cargo install that builds shop runs in the rust-build builder stage, and the shipped image starts from fedora-bootc 600 lines later and compiles nothing at runtime. rust was the survivor of the 2026-08-17 ruling, kept on the claim that sandod refuses to compile anywhere but its configured build host. That is an argument about a machine being a build host, not about the image working, and a build host is now something a mint asks for. Both build hosts in the fleet need LANGS=rust passed, which the two migration notes now say rather than leaving it to be discovered by a machine that cannot build. The C toolchain goes with it. gcc, cpp, binutils and make were in every image because rust requires them, which the trim block already called a language decision rather than an accident. The builder already had the language picker with its size deltas, so the change there is the default and what the rows say about inheriting a cost versus spending one.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-28 18:15 UTC
Commit: 9f63d60e8fffcf80b82cce6535be30de1e8b31e1
Parent: 781a8a4
4 files changed, +94 insertions, -49 deletions
M Containerfile +51 -25
@@ -763,11 +763,23 @@
763 763 # the gate lives here so it holds even when the TUI is bypassed.
764 764 #
765 765 # THE DEFAULT IS WHAT THE IMAGE ITSELF REQUIRES, ruled by Max 2026-08-17.
766 - # So `rust`, alone. Rust is not a preference here: sandod shells out to
767 - # `cargo build --release` and refuses to compile anywhere but its
768 - # configured build host, so an image that quietly stopped carrying cargo
769 - # would break Sando rather than merely save disk. Nothing else in the
770 - # image makes that claim.
766 + # EMPTY, ruled by Max 2026-08-27: nothing in the image requires a
767 + # toolchain, so a stock image carries none. A toolchain that is merely
768 + # useful is picked at mint time.
769 + #
770 + # The 2026-08-17 ruling is unchanged; what changed is that it was never
771 + # applied to its last survivor. `rust` was kept on the claim that sandod
772 + # shells out to `cargo build --release` and would break without cargo,
773 + # which is an argument about the machine being a BUILD HOST rather than
774 + # about the image requiring rust to function. Measured 2026-08-27: the
775 + # `cargo install --git ...shop.git` at the top of this file runs in the
776 + # `rust-build` builder stage, and the shipped image starts from
777 + # `fedora-bootc` 600 lines later and compiles nothing at runtime. So the
778 + # toolchain that builds shop was never the toolchain that ships.
779 + #
780 + # The go/python/zig sentence below is therefore the whole rule now,
781 + # applied to every language including rust, rather than a note about
782 + # three of them.
771 783 #
772 784 # GO WAS IN THIS DEFAULT AND CAME BACK OUT THE SAME DAY. Recording both
773 785 # rulings, because the second reverses the first and a bare `rust` reads
@@ -801,8 +813,17 @@
801 813 # the trade, and it is reversible at zero cost, which most things here are
802 814 # not.
803 815 #
804 - # python and zig stay off for the reason go now is: nothing in the image
805 - # requires them.
816 + # python and zig are off for the same reason, which is the rule: nothing
817 + # in the image requires them.
818 + #
819 + # THE BUILD-HOST COST, and it is the one that bites this fleet. A build
820 + # host is now a mint-time choice rather than a property of any profile.
821 + # fw13's cutover turns on the machine still being able to compile, and an
822 + # Alloy astra is a build host too; both need `LANGS=rust` passed when
823 + # their images are minted, which wiki `alloy-fw13-migration` and
824 + # `alloy-astra-migration` now say. Sando is the same fact from the other
825 + # end: sandod refuses to compile anywhere but its configured build host,
826 + # so that host's image is one that asked for rust.
806 827 #
807 828 # js is the newest arm and is off by the same rule. Nothing in the image
808 829 # is JavaScript. What asks for it is the build-host role: Sando's
@@ -826,7 +847,7 @@
826 847 # estimate was measuring a transaction this arm does not run. Node costs
827 848 # about 40% of what the task predicted. nodejs-full-i18n, 31.6 MiB of the
828 849 # estimate, is confirmed absent from the built image.
829 - ARG LANGS=rust
850 + ARG LANGS=
830 851
831 852 # DB is the database the image carries, and it exists because the version
832 853 # matters more than the presence. `postgres16` installs postgresql16 and
@@ -931,7 +952,7 @@
931 952 client:*|server:none) ;; \
932 953 server:*) echo "PROFILE=server ships no graphical session and cannot carry BROWSER=$BROWSER" >&2; exit 1 ;; \
933 954 esac; \
934 - echo "building profile=$PROFILE browser=$BROWSER langs=$LANGS trim=$TRIM db=$DB"
955 + echo "building profile=$PROFILE browser=$BROWSER langs=${LANGS:-none} trim=$TRIM db=$DB"
935 956
936 957 # =====================================================================
937 958 # Install-time filtering: translations and documentation
@@ -1177,12 +1198,13 @@
1177 1198 # Measured on the built images rather than predicted, because the
1178 1199 # prediction was half wrong: gcc-c++, libstdc++-devel, xsel, xdg-utils,
1179 1200 # libX11 and man-pages are gone from `server`, and gcc, cpp, binutils and
1180 - # make are STILL THERE. `LANGS` defaults to rust and is unconditional, and
1181 - # rust requires them, which the LANGS block further down says outright.
1182 - # So the C compiler on a headless box is a language decision and not an
1183 - # accident, and it is the LANGS default that would have to change to shed
1184 - # it. The flag removes the C++ half and the X half; it does not remove the
1185 - # toolchain.
1201 + # make were STILL THERE, because `LANGS` defaulted to rust and rust
1202 + # requires them. So the C compiler on a headless box was a language
1203 + # decision and not an accident, and the LANGS default was what would have
1204 + # to change to shed it. It changed on 2026-08-27: `LANGS` is empty by
1205 + # default, so a stock image of either profile carries no compiler at all
1206 + # and one minted with `LANGS=rust` gets the C toolchain back along with
1207 + # it. The flag itself still only removes the C++ half and the X half.
1186 1208 #
1187 1209 # One recommend IS wanted and is now named below rather than inherited:
1188 1210 # `helix-parsers`. It is 185 MiB of tree-sitter grammars and it is what
@@ -1807,7 +1829,8 @@
1807 1829 && dnf clean all \
1808 1830 # bibata-cursor-theme installs FOURTEEN directories and Alloy names one.
1809 1831 # At 179 MB it was the third-largest package in the image, behind the
1810 - # browser and helix's parsers, and ahead of rustc. Fedora ships no
1832 + # browser and helix's parsers, and ahead of rustc on a mint that asked
1833 + # for rust (nothing does by default since 2026-08-27). Fedora ships no
1811 1834 # per-variant subpackage, so a prune is the only lever.
1812 1835 #
1813 1836 # Measured in the built image: Bibata-Modern-{Amber,Classic,Ice} at 27 MB
@@ -1914,21 +1937,24 @@
1914 1937 # the way this stops being one number for everyone", and this is that,
1915 1938 # arrived at from the builder rather than from mint time.
1916 1939 #
1917 - # Rust is in the default set and that is not politeness: sandod shells
1918 - # out to `cargo build --release` and refuses to compile anywhere but its
1919 - # configured build host, so an image that quietly stopped carrying cargo
1920 - # would break Sando rather than merely save disk. It costs 610 MiB across
1921 - # 16 packages, most of it rust-std-static (164 MiB) and llvm-libs (139
1922 - # MiB), which is an order of magnitude above the hardware-health group
1923 - # and by a distance the largest optional thing in the image.
1940 + # Rust is not in the default set any more (2026-08-27): nothing in the
1941 + # image requires it, and the Sando argument that kept it is about a
1942 + # machine being a build host rather than about the image working. It
1943 + # costs 610 MiB across 16 packages, most of it rust-std-static (164 MiB)
1944 + # and llvm-libs (139 MiB), which is an order of magnitude above the
1945 + # hardware-health group and by a distance the largest optional thing in
1946 + # the image — so it is exactly the kind of thing a mint should be asked
1947 + # about rather than handed.
1924 1948 #
1925 1949 # No separate linker line: `rust` pulls gcc, binutils and glibc-devel, so
1926 1950 # cc arrives with it. rustup is deliberately not offered here — it
1927 1951 # installs into $HOME, needs nothing from the image, and stays the answer
1928 1952 # for a pinned or nightly toolchain.
1929 1953 #
1930 - # Unconditional: languages are as useful on a build host as on a laptop,
1931 - # which is most of why the build-host role works at all.
1954 + # Profile-blind: languages are as useful on a build host as on a laptop,
1955 + # which is most of why the build-host role works at all. The empty
1956 + # default is profile-blind for the same reason — a client image has no
1957 + # more claim on a compiler than a server one.
1932 1958 RUN set -eu; \
1933 1959 for lang in $(echo "$LANGS" | tr ',' ' '); do \
1934 1960 case "$lang" in \
M docs/IMAGE.md +7 -4
@@ -117,8 +117,10 @@
117 117 deployed, and the 0.9 GB between those two numbers is the subject of half this section.
118 118
119 119 Every figure below is from that build, so they describe an image built with Go in it.
120 - The default became `LANGS=rust` later the same day, which takes the language layer down
121 - with it; the numbers are left as measured rather than adjusted on paper.
120 + The default became `LANGS=rust` later the same day and then empty on 2026-08-27, which
121 + takes the whole language layer out of a stock image; the numbers are left as measured
122 + rather than adjusted on paper. A default mint today carries no toolchain at all, so the
123 + 778 MB language row below is a layer only a machine that asked for one builds.
122 124
123 125 The same applies to the browser, and more sharply. `BROWSER=helium` was removed on
124 126 2026-08-18 and Firefox is the only answer now, so the browser layer below is a
@@ -138,7 +140,7 @@
138 140 | Size | Layer | Why it is that size |
139 141 |------|-------|---------------------|
140 142 | 1.44 GB | sway, `xdg-desktop-portal{,-gtk,-wlr}`, the session | The product. This is the desktop. |
141 - | 778 MB | `LANGS=rust,go` | As measured. Go is 230 MB of this layer, and 356 MB by exclusive closure once the packages only it pulls in are counted; that gap is the two questions the measurements answer, not a disagreement. Go left the default on 2026-08-17, so a stock image builds this layer at `LANGS=rust`. Argued at `ARG LANGS`. |
143 + | 778 MB | `LANGS=rust,go` | As measured. Go is 230 MB of this layer, and 356 MB by exclusive closure once the packages only it pulls in are counted; that gap is the two questions the measurements answer, not a disagreement. Go left the default on 2026-08-17 and rust on 2026-08-27, so a stock image does not build this layer at all. Argued at `ARG LANGS`. |
142 144 | 674 MB | `helium-browser-bin` | The product. Superseded: this is Helium, removed 2026-08-18, and Firefox's layer is unmeasured. |
143 145 | 663 MB | Base package list | The stack, per [STACK.md](STACK.md). |
144 146 | 170 MB | initramfs + `rpmostree-unpackaged-content` | Bootable image. |
@@ -154,7 +156,8 @@
154 156 syntax highlighting. Settled.
155 157 - **`rust-std-static`, 166 MB.** The name suggests static-musl targets nothing here
156 158 builds. It is the standard library: `rust` carries a versioned hard requirement on it,
157 - and removing it takes `rust` and `cargo` with it. Not separable from `LANGS=rust`.
159 + and removing it takes `rust` and `cargo` with it. Not separable from `LANGS=rust`, and
160 + absent entirely from a mint that did not ask for rust.
158 161 - **`llvm-libs`, 139 MB.** `rpm -q --whatrequires llvm-libs` returns nothing, which is
159 162 what makes it look orphaned, and the query is the thing that is wrong: dependents
160 163 require the soname. `libLLVM.so.21.1()(64bit)` is required by `mesa-dri-drivers`,
@@ -281,6 +281,8 @@
281 281 /// Rust's figure is the Containerfile's own measurement, and it is the
282 282 /// reason this row exists: 610 MiB is an order of magnitude above the
283 283 /// hardware-health group and by a distance the largest thing in the image.
284 + /// Off by default since 2026-08-27, so like Go's it is now a number
285 + /// somebody is deciding to spend rather than one they are inheriting.
284 286 ///
285 287 /// Go's is its marginal cost from the 2026-08-17 exclusive-closure pass —
286 288 /// what turning this row on actually adds, rather than what `golang`
@@ -288,7 +290,7 @@
288 290 /// number is what someone is deciding to spend.
289 291 const fn label(self) -> &'static str {
290 292 match self {
291 - Lang::Rust => "rust (610 MiB; needed for the build-host role)",
293 + Lang::Rust => "rust (610 MiB; the build-host role asks for this)",
292 294 Lang::Go => "go (356 MB; rebuilds syncthing, restic, tailscale)",
293 295 Lang::Python => "python",
294 296 Lang::Zig => "zig",
@@ -461,18 +463,22 @@
461 463 profile: Profile::default(),
462 464 browser: Browser::default(),
463 465 // The default is what the image itself requires (Max,
464 - // 2026-08-17), which is Rust and nothing else: sandod refuses to
465 - // compile anywhere but its configured host, so an image that
466 - // silently stopped being able to build Rust would break Sando
467 - // rather than merely save disk.
466 + // 2026-08-17), and as of 2026-08-27 that is nothing: measured, the
467 + // shipped image compiles nothing at runtime, since the `cargo
468 + // install` that builds shop runs in a builder stage the final image
469 + // is not built from. A toolchain that is merely useful is picked
470 + // here, at mint time.
468 471 //
469 - // Go was in this default earlier the same day, under the rule "the
470 - // default is what compiles the shipped stack" — 7 of the shipped
471 - // binaries are Go (syncthing, restic, tailscale, gopass, direnv,
472 - // fzf, cliphist). That rule already stopped short of the C tier, so
473 - // it was two-thirds true, and Go's marginal cost measured 356 MB.
474 - // Reversed. `ARG LANGS` in the Containerfile carries both rulings.
475 - langs: BTreeSet::from([Lang::Rust]),
472 + // Rust survived the first ruling on the claim that sandod refuses
473 + // to compile anywhere but its configured host. That is an argument
474 + // about a machine being a build host, not about the image working,
475 + // and a build host is now a thing a mint asks for: fw13 and astra
476 + // both take `LANGS=rust`. Go was in the default earlier still,
477 + // under "the default is what compiles the shipped stack", which
478 + // already stopped short of the C tier and so was two-thirds true.
479 + // `ARG LANGS` in the Containerfile carries every one of those
480 + // rulings.
481 + langs: BTreeSet::new(),
476 482 hostname: String::new(),
477 483 pubkey: String::new(),
478 484 artifact: Artifact::default(),
@@ -1471,9 +1477,9 @@
1471 1477 let choices = Choices::default();
1472 1478 assert_eq!(choices.profile, Profile::Client);
1473 1479 assert_eq!(choices.browser, Browser::Firefox);
1474 - // Rust, and only Rust: it is the one toolchain the image itself needs,
1475 - // for the build-host role. See the comment on `Choices::default`.
1476 - assert_eq!(choices.langs, BTreeSet::from([Lang::Rust]));
1480 + // No toolchain. The image requires none to function, and a build
1481 + // host asks for one at mint. See the comment on `Choices::default`.
1482 + assert!(choices.langs.is_empty());
1477 1483 assert_eq!(choices.artifact, Artifact::Iso);
1478 1484 // Trimmed by default. What it costs is foreign-architecture emulation,
1479 1485 // which the house rules forbid using in the first place.
@@ -1497,7 +1503,7 @@
1497 1503 // the two defaults are one decision written in two files, and a build
1498 1504 // that bypasses the TUI must get the same stack the TUI would have
1499 1505 // asked for.
1500 - assert_eq!(args[2].1, "rust");
1506 + assert_eq!(args[2].1, "");
1501 1507 assert_eq!(args[3].1, "unused");
1502 1508 assert_eq!(args[4].1, "none");
1503 1509 }
@@ -265,13 +265,23 @@
265 265 let source =
266 266 std::fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/image.rs"))
267 267 .expect("cannot read image.rs");
268 + // Either spelling of the default, because an empty default is a real
269 + // answer here and `BTreeSet::from([])` is not how it is written.
268 270 let builder = source
269 271 .lines()
270 - .find_map(|line| line.trim().strip_prefix("langs: BTreeSet::from(["))
271 - .and_then(|rest| rest.split(']').next())
272 + .find_map(|line| line.trim().strip_prefix("langs: BTreeSet::"))
272 273 .expect("no langs default in image.rs");
274 + let builder = builder
275 + .strip_prefix("from([")
276 + .and_then(|rest| rest.split(']').next())
277 + .unwrap_or("");
273 278
274 - let from_arg: Vec<&str> = arg.trim().split(',').collect();
279 + let arg = arg.trim();
280 + let from_arg: Vec<&str> = if arg.is_empty() {
281 + Vec::new()
282 + } else {
283 + arg.split(',').collect()
284 + };
275 285 let from_builder: Vec<String> = builder
276 286 .split(',')
277 287 .filter_map(|entry| entry.trim().strip_prefix("Lang::").map(str::to_lowercase))
@@ -279,7 +289,7 @@
279 289
280 290 assert_eq!(
281 291 from_arg, from_builder,
282 - "ARG LANGS={arg} and image.rs's default disagree; they are one decision",
292 + "ARG LANGS={arg:?} and image.rs's default disagree; they are one decision",
283 293 );
284 294 }
285 295