Skip to main content

max / alloy

Default the toolchains to what compiles the shipped stack The default was `rust`, justified by the build-host role: sandod refuses to compile anywhere but its configured host, so an image that could not build Rust would break Sando. True, and not the whole rule. The rule (Max, 2026-08-17) is that the default should be whatever compiles the rest of the default stack, so a user who wants to rebuild a program that shipped with the image does not have to install a toolchain first. Measured off the built client image rather than assumed, by reading each shipped binary for a Go build id or a rustc path: 14 are Rust (helix, yazi, nushell, starship, the CLI tier, satty, plus alloy and shop themselves) and 7 are Go (syncthing, restic, tailscale, gopass, direnv, fzf, cliphist). So the default is `rust,go`. Go is the cheaper half, 95 MiB inbound against rust's 176, and it covers the continuity tier, which is the part of the stack most likely to be wanted ahead of what Fedora carries. Verified on a built image: go1.25.12 present, /usr/lib/alloy/build.toml records langs = ["rust", "go"], 6.17 GB against 5.94 without it. The rule stops at C and the comment says so rather than implying it is uniform. gcc arrives with rust, so a single-file C program builds, but sway and mako want meson, ninja, wayland-protocols and a spread of -devel packages that no language toggle models. Those are rebuilt by layering a buildroot. python and zig stay off for the reason go is now on: nothing shipped is written in either. The two defaults are one decision written in two files, and nothing connected them, so tests/profile_split.rs now parses `ARG LANGS=` and compares it to the literal in image.rs. Verified against a negative control by setting them apart deliberately before reverting, which is the standard the rest of that file holds itself to.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-17 16:43 UTC
Signed with PGP, not checked
Commit: ae9296479fa4edda8b91b0a598d75d76af433999
Parent: 11291bd
4 files changed, +120 insertions, -29 deletions
M Cargo.lock +24 -20
@@ -2643,22 +2643,6 @@
2643 2643 "winnow",
2644 2644 ]
2645 2645
2646 - [[patch.unused]]
2647 - name = "kberg"
2648 - version = "0.1.0"
2649 -
2650 - [[patch.unused]]
2651 - name = "ops-status"
2652 - version = "0.1.0"
2653 -
2654 - [[patch.unused]]
2655 - name = "painhours"
2656 - version = "0.1.0"
2657 -
2658 - [[patch.unused]]
2659 - name = "tagtree"
2660 - version = "0.4.0"
2661 -
2662 2646 [[patch.unused]]
2663 2647 name = "quasi-axum"
2664 2648 version = "0.18.0"
@@ -2691,6 +2675,30 @@
2691 2675 name = "quasi-webview"
2692 2676 version = "0.18.0"
2693 2677
2678 + [[patch.unused]]
2679 + name = "docengine"
2680 + version = "0.7.0"
2681 +
2682 + [[patch.unused]]
2683 + name = "quasi-type"
2684 + version = "0.1.0"
2685 +
2686 + [[patch.unused]]
2687 + name = "kberg"
2688 + version = "0.1.0"
2689 +
2690 + [[patch.unused]]
2691 + name = "ops-status"
2692 + version = "0.1.0"
2693 +
2694 + [[patch.unused]]
2695 + name = "painhours"
2696 + version = "0.1.0"
2697 +
2698 + [[patch.unused]]
2699 + name = "tagtree"
2700 + version = "0.4.0"
2701 +
2694 2702 [[patch.unused]]
2695 2703 name = "synckit-client"
2696 2704 version = "0.8.0"
@@ -2698,7 +2706,3 @@
2698 2706 [[patch.unused]]
2699 2707 name = "synckit-config"
2700 2708 version = "0.2.0"
2701 -
2702 - [[patch.unused]]
2703 - name = "docengine"
2704 - version = "0.7.0"
M Containerfile +28 -1
@@ -595,7 +595,34 @@
595 595 # separated, and validated against a curated set below rather than
596 596 # treated as a package list — the builder is not a package manager, and
597 597 # the gate lives here so it holds even when the TUI is bypassed.
598 - ARG LANGS=rust
598 + #
599 + # THE DEFAULT IS WHAT COMPILES THE SHIPPED STACK, ruled by Max 2026-08-17:
600 + # someone who wants to rebuild a program Alloy ships should not have to
601 + # install a toolchain first. Measured against the client image rather than
602 + # assumed, by reading each shipped binary for a Go build id or a rustc
603 + # path:
604 + #
605 + # Rust hx yazi nu starship zoxide btm dua rg fd bat eza satty, plus
606 + # alloy and shop themselves. 14.
607 + # Go direnv fzf gopass syncthing restic tailscale cliphist. 7.
608 + # C sway mako swaylock, and the hardware-health group.
609 + #
610 + # So `rust,go`. Go is the cheaper half at 95 MiB inbound against rust's
611 + # 176, and it covers the whole continuity tier — syncthing, restic and
612 + # tailscale are the programs most likely to be wanted a version ahead of
613 + # what Fedora carries.
614 + #
615 + # The C tier is deliberately NOT covered and the rule stops short of it.
616 + # `gcc` arrives with rust, so a single-file C program builds, but sway and
617 + # mako want meson, ninja, wayland-protocols and a spread of -devel
618 + # packages that no language toggle models. Naming that here rather than
619 + # pretending the rule is uniform: the three C programs in the stack are
620 + # the ones a user rebuilds by layering a buildroot, not by flipping a
621 + # toggle in the builder.
622 + #
623 + # python and zig stay off for the same reason go is now on: nothing
624 + # shipped is written in either.
625 + ARG LANGS=rust,go
599 626
600 627 # TRIM is the one builder choice about the *base* rather than about Alloy.
601 628 # fedora-bootc is a general-purpose server base, and three of the things it
@@ -410,11 +410,19 @@
410 410 Self {
411 411 profile: Profile::default(),
412 412 browser: Browser::default(),
413 - // Rust on by default: the build-host role is a hard requirement
414 - // (sandod refuses to compile anywhere but its configured host), and
415 - // an image that silently stopped being able to build Rust would
416 - // break Sando rather than merely save disk.
417 - langs: BTreeSet::from([Lang::Rust]),
413 + // The default is whatever compiles the stack Alloy ships (Max,
414 + // 2026-08-17): someone rebuilding a program that came with the
415 + // image should not have to install a toolchain to do it. Measured
416 + // off the client image, 14 shipped binaries are Rust (helix, yazi,
417 + // nushell, starship, the whole CLI tier, plus alloy and shop) and 7
418 + // are Go (syncthing, restic, tailscale, gopass, direnv, fzf,
419 + // cliphist), so both are on and python and zig are not.
420 + //
421 + // Rust also carries the build-host role, which is a hard
422 + // requirement on its own: sandod refuses to compile anywhere but
423 + // its configured host, so an image that silently stopped being able
424 + // to build Rust would break Sando rather than merely save disk.
425 + langs: BTreeSet::from([Lang::Rust, Lang::Go]),
418 426 hostname: String::new(),
419 427 pubkey: String::new(),
420 428 artifact: Artifact::default(),
@@ -1384,11 +1392,13 @@
1384 1392 use super::*;
1385 1393
1386 1394 #[test]
1387 - fn the_default_is_the_desktop_with_rust() {
1395 + fn the_default_is_the_desktop_with_what_compiles_the_stack() {
1388 1396 let choices = Choices::default();
1389 1397 assert_eq!(choices.profile, Profile::Client);
1390 1398 assert_eq!(choices.browser, Browser::Helium);
1391 - assert!(choices.langs.contains(&Lang::Rust));
1399 + // Both, and only both: the shipped stack is Rust and Go, and nothing
1400 + // in it is python or zig. See the comment on `Choices::default`.
1401 + assert_eq!(choices.langs, BTreeSet::from([Lang::Rust, Lang::Go]));
1392 1402 assert_eq!(choices.artifact, Artifact::Iso);
1393 1403 // Trimmed by default. What it costs is foreign-architecture emulation,
1394 1404 // which the house rules forbid using in the first place.
@@ -1404,7 +1414,12 @@
1404 1414 let names: Vec<&str> = args.iter().map(|(k, _)| k.as_str()).collect();
1405 1415 assert_eq!(names, ["PROFILE", "BROWSER", "LANGS", "TRIM"]);
1406 1416 assert_eq!(args[0].1, "client");
1407 - assert_eq!(args[2].1, "rust");
1417 + // Comma-joined in the enum's declared order, and this is also the
1418 + // literal the Containerfile's own `ARG LANGS` default has to match:
1419 + // the two defaults are one decision written in two files, and a build
1420 + // that bypasses the TUI must get the same stack the TUI would have
1421 + // asked for.
1422 + assert_eq!(args[2].1, "rust,go");
1408 1423 assert_eq!(args[3].1, "unused");
1409 1424 }
1410 1425
@@ -237,3 +237,48 @@
237 237 "the server prune removes the console palette",
238 238 );
239 239 }
240 +
241 + /// The builder's default language set and the Containerfile's `ARG LANGS`
242 + /// default are one decision written in two files, so they are asserted equal.
243 + ///
244 + /// The rule behind the value (Max, 2026-08-17) is that the default is whatever
245 + /// compiles the stack Alloy ships: a user rebuilding a program that came with
246 + /// the image should not have to install a toolchain first. That makes the
247 + /// value a measurement of the shipped binaries rather than a preference, and
248 + /// it makes drift between the two files a real defect rather than an
249 + /// inconsistency — a build that bypasses the TUI has to get the stack the TUI
250 + /// would have asked for, and `alloy image` printing a command whose `LANGS`
251 + /// matches the ARG it is overriding is how anyone would ever notice.
252 + ///
253 + /// Asserted against the literal in `image.rs` rather than by importing it: the
254 + /// crate is a binary, the test is a text check over the Containerfile in the
255 + /// spirit of the rest of this file, and the point is that the two spellings
256 + /// agree.
257 + #[test]
258 + fn the_langs_default_matches_the_builder() {
259 + let text = containerfile();
260 + let arg = text
261 + .lines()
262 + .find_map(|line| line.strip_prefix("ARG LANGS="))
263 + .expect("no ARG LANGS in the Containerfile");
264 +
265 + let source =
266 + std::fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/image.rs"))
267 + .expect("cannot read image.rs");
268 + let builder = source
269 + .lines()
270 + .find_map(|line| line.trim().strip_prefix("langs: BTreeSet::from(["))
271 + .and_then(|rest| rest.split(']').next())
272 + .expect("no langs default in image.rs");
273 +
274 + let from_arg: Vec<&str> = arg.trim().split(',').collect();
275 + let from_builder: Vec<String> = builder
276 + .split(',')
277 + .filter_map(|entry| entry.trim().strip_prefix("Lang::").map(str::to_lowercase))
278 + .collect();
279 +
280 + assert_eq!(
281 + from_arg, from_builder,
282 + "ARG LANGS={arg} and image.rs's default disagree; they are one decision",
283 + );
284 + }