Skip to main content

max / alloy

Let a server profile carry fontconfig when it builds Tauri apps The font guard at step 101 held that a server image carries no fontconfig at all, and it fired on the first astra mint anyone has ever run. It was right to ask and wrong to conclude: astra is the first machine to be both PROFILE=server and GUI=tauri, because it builds the arm64 Tauri releases natively and nothing here cross-compiles. Those builds want gtk3-devel, libsoup3, librsvg2 and webkit2gtk4.1, and that set brings the GTK and WebKit runtime with fontconfig underneath it. So the server half splits on $GUI. GUI=none keeps the original claim unchanged. GUI=tauri asserts the other direction instead, because a build host missing fontconfig fails at a link months later rather than at a boot. Every other claim survives on both arms: no house faces, no font package from the client's list, no emoji. What a headless machine may not have is a face. Tools that answer questions about faces are the price of building the apps. The earlier fontconfig assertion is untouched and still true: it runs before the GUI block, and its comment said it was the only thing that could put fontconfig on a server image, which is now only true where it stands. font_profile.rs learns the three-armed shape rather than being relaxed. Handing that block to if_branches returns a "server" half that is only the GUI=tauri arm, which would assert absence against the branch that deliberately does not claim it. $GUI is passed at every call site rather than defaulted, since a default would silently decide which half of the split each test exercises.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_013wvegQEzB5piwPowYQ3ZbV
Author: Max Johnson <me@maxj.phd> · 2026-09-04 21:10 UTC
Signed with PGP, not checked
Commit: d67e72fa567ca3f0ab317b3ad12d5dd645e86c57
Parent: 4fab4cd
2 files changed, +189 insertions, -32 deletions
M Containerfile +45 -10
@@ -1219,15 +1219,20 @@
1219 1219 # requires it. The base has none either, so declining to install it here is
1220 1220 # what actually removes it rather than a line that a dependency quietly puts
1221 1221 # back. It takes 784 KiB of tools, 120 KiB of cache and the 208 KiB of faces
1222 - # below with it. What the measurement does not cover is a server-profile
1223 - # image: none was built for this change, so what a server image ends up
1224 - # carrying is read off the package set rather than seen on disk.
1222 + # below with it. What the measurement did not cover, when it was made, was a
1223 + # server-profile image: none had been built, so what a server image ends up
1224 + # carrying was read off the package set rather than seen on disk.
1225 1225 #
1226 - # The `else` branch is the claim from the other side, and it is a real one: it
1227 - # holds that the base still ships no fontconfig, so this line is the only thing
1228 - # that could put one on a server image. The claim that matters more, that
1229 - # nothing installed later drags it back, is asserted at the end of the file,
1230 - # after every package install, where it can be true.
1226 + # The `else` branch is the claim from the other side, and it is a real one, with
1227 + # one word since measured: it holds that the base still ships no fontconfig, so
1228 + # this line is the only thing that could put one on a server image AT THIS POINT
1229 + # IN THE BUILD. That last part is not a hedge. `GUI=tauri` installs the GTK and
1230 + # WebKit devel set further down and drags fontconfig in underneath it, which is
1231 + # what the first astra mint found on 2026-09-04. This assertion is unaffected --
1232 + # it runs before that block and is still true where it stands -- and the claim
1233 + # that matters more, about what the image ends up with, is asserted at the end of
1234 + # the file, after every package install, where it can be true. That one now
1235 + # splits on $GUI; see it for the reasoning.
1231 1236 # =====================================================================
1232 1237 RUN set -eu; \
1233 1238 if [ "$PROFILE" = client ]; then \
@@ -4740,6 +4745,24 @@
4740 4745 # etc/skel/.config/fontconfig with the rest of the skeleton, so there are no
4741 4746 # Alloy aliases left to resolve and the answer would be the base's opinion,
4742 4747 # which is the exact mistake the HOME=/etc/skel note above warns about.
4748 + #
4749 + # THE SERVER BRANCH SPLITS ON $GUI, and the split is a measured answer to the
4750 + # question the old assertion asked when it fired. It held that a server image
4751 + # carries no fontconfig at all, and astra is the first machine to be both
4752 + # `PROFILE=server` and `GUI=tauri`: it builds the arm64 Tauri releases, natively,
4753 + # because nothing here cross-compiles. Those builds want gtk3-devel, libsoup3,
4754 + # librsvg2 and webkit2gtk4.1, and that set drags in the GTK and WebKit runtime
4755 + # with fontconfig underneath it. Measured 2026-09-04, at step 101 of 103, on the
4756 + # first astra mint anyone had ever run.
4757 + #
4758 + # So the payload argument still holds where it was made and does not reach this
4759 + # machine. `GUI=none` keeps the original claim, unchanged and now stated as what
4760 + # it always meant. `GUI=tauri` asserts the other direction instead -- fontconfig
4761 + # has to BE there, since a build host missing it fails at link time rather than
4762 + # at boot -- and every other claim on this branch is kept: no house faces, no
4763 + # font package from the client's list, no emoji. What a headless build host may
4764 + # not have is a face. Tools that answer questions about faces are the price of
4765 + # building the apps, and the apps are why the machine exists.
4743 4766 RUN set -eux; \
4744 4767 if [ "$PROFILE" = client ]; then \
4745 4768 for lang in ja zh-cn zh-tw ko ar he hi th bn ta; do \
@@ -4754,9 +4777,9 @@
4754 4777 || { 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; }; \
4755 4778 [ "$(fc-list ':charset=1F600' family | wc -l)" -eq 0 ] \
4756 4779 || { echo "an emoji font arrived; docs/STACK.md says none is shipped, so update the decision or drop the font" >&2; exit 1; }; \
4757 - else \
4780 + elif [ "$GUI" = none ]; then \
4758 4781 ! command -v fc-list >/dev/null \
4759 - || { echo "profile=server 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; }; \
4782 + || { 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; }; \
4760 4783 test ! -e /usr/share/fonts/quasi \
4761 4784 || { 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; }; \
4762 4785 for pkg in default-fonts-cjk-sans default-fonts-other-sans google-noto-sans-mono-cjk-vf-fonts; do \
@@ -4766,6 +4789,18 @@
4766 4789 ! rpm -qa | grep -qi emoji \
4767 4790 || { 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; }; \
4768 4791 echo "fonts: no font stack on this profile, which is what a headless machine draws with"; \
4792 + else \
4793 + command -v fc-list >/dev/null \
4794 + || { 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; }; \
4795 + test ! -e /usr/share/fonts/quasi \
4796 + || { 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; }; \
4797 + for pkg in default-fonts-cjk-sans default-fonts-other-sans google-noto-sans-mono-cjk-vf-fonts; do \
4798 + ! rpm -q --quiet "$pkg" \
4799 + || { 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; }; \
4800 + done; \
4801 + ! rpm -qa | grep -qi emoji \
4802 + || { 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; }; \
4803 + echo "fonts: fontconfig rides in with GUI=$GUI, and no face does"; \
4769 4804 fi
4770 4805
4771 4806 # =====================================================================
@@ -138,6 +138,28 @@
138 138 )
139 139 }
140 140
141 + /// The three arms of the font guard, which is `if PROFILE / elif GUI / else`.
142 + ///
143 + /// [`if_branches`] splits on the one branch-joining `else` and is right for
144 + /// every two-armed conditional in this file. The guard at the end of the
145 + /// Containerfile is not one: its server half splits again on `$GUI`, because
146 + /// `PROFILE=server GUI=tauri` is a real machine (astra, which builds the arm64
147 + /// Tauri releases natively) and it carries fontconfig underneath GTK and WebKit
148 + /// while carrying no face at all. Handing that block to `if_branches` returns a
149 + /// "server" half that is only the `GUI=tauri` arm, and the absence claims would
150 + /// then be asserted against the branch that deliberately does not make them.
151 + ///
152 + /// Returns (client, server with GUI=none, server with any other GUI).
153 + fn three_arms(block: &str) -> (String, String, String) {
154 + let elif = block
155 + .find("elif ")
156 + .unwrap_or_else(|| panic!("expected an `elif` arm in this block:\n{block}"));
157 + let client = block[..elif].to_string();
158 + let rest = &block[elif..];
159 + let (headless, gui) = if_branches(rest);
160 + (client, headless, gui)
161 + }
162 +
141 163 /// Whether the conditional the branches came from tests `$PROFILE`.
142 164 ///
143 165 /// [`if_branches`] splits on the `else` and says nothing about what was
@@ -282,17 +304,22 @@
282 304 );
283 305 }
284 306
285 - /// Run an extracted block against a fake root, with `$PROFILE` set and only the
286 - /// stubs on `PATH`.
307 + /// Run an extracted block against a fake root, with `$PROFILE` and `$GUI` set
308 + /// and only the stubs on `PATH`.
287 309 ///
288 310 /// `PATH` is the fake root's bin directory and nothing else, which is the point
289 311 /// on `server`: the branch asserts `fc-list` is not reachable, and that claim
290 312 /// can only be tested where the host's own fontconfig cannot answer it.
291 - fn run_block(script: &str, profile: &str, bin: &Path) -> std::process::Output {
313 + ///
314 + /// `$GUI` is passed rather than defaulted because the server branch splits on
315 + /// it, and a default here would decide which half of that split every test in
316 + /// this file exercises without saying so.
317 + fn run_block(script: &str, profile: &str, gui: &str, bin: &Path) -> std::process::Output {
292 318 Command::new(shell())
293 319 .arg("-c")
294 320 .arg(script)
295 321 .env("PROFILE", profile)
322 + .env("GUI", gui)
296 323 .env("PATH", format!("{}", bin.display()))
297 324 .output()
298 325 .expect("running the extracted block")
@@ -469,21 +496,63 @@
469 496 #[test]
470 497 fn the_server_branch_asserts_absence_rather_than_presence() {
471 498 let guard = block_with("no font covers");
472 - let (_, server) = if_branches(&guard);
499 + let (_, headless, gui) = three_arms(&guard);
500 +
501 + for (label, arm) in [("GUI=none", &headless), ("GUI=tauri", &gui)] {
502 + assert!(
503 + arm.contains("test ! -e") && arm.contains("/usr/share/fonts/quasi"),
504 + "the {label} server arm does not prove the house faces are absent:\n{arm}",
505 + );
506 + assert!(
507 + !arm.contains("fc-list ':family=Quasi"),
508 + "the {label} server arm asks fontconfig about the house faces, on a profile \
509 + that installs no face whatever it carries to read one with:\n{arm}",
510 + );
511 + }
512 + assert!(
513 + headless.contains("! command -v fc-list"),
514 + "the headless server arm does not prove fontconfig itself is absent, so a \
515 + package dragging it back in would go unreported:\n{headless}",
516 + );
517 + }
518 +
519 + /// The other direction, which is the half that is easy to get wrong.
520 + ///
521 + /// A server that builds Tauri apps has to HAVE fontconfig: gtk3-devel and
522 + /// webkit2gtk4.1-devel bring the GTK and WebKit runtime and fontconfig sits
523 + /// under it, and a build host missing it fails at link time rather than at boot.
524 + /// So that arm asserts presence, and asserting absence there is what the first
525 + /// astra mint hit at step 101 of 103 on 2026-09-04.
526 + ///
527 + /// What must NOT weaken with it is the claim that actually mattered: no face.
528 + /// Tools that answer questions about faces are the price of building the apps;
529 + /// a face on a machine that rasterises nothing is payload with no reader, and
530 + /// that is still refused. The loop above covers the face half for both arms;
531 + /// this covers the package half, which is the one a careless edit would drop.
532 + #[test]
533 + fn the_tauri_server_arm_asserts_presence_and_still_refuses_every_face() {
534 + let guard = block_with("no font covers");
535 + let (_, _, gui) = three_arms(&guard);
473 536
474 537 assert!(
475 - server.contains("test ! -e") && server.contains("/usr/share/fonts/quasi"),
476 - "the server branch does not prove the house faces are absent:\n{server}",
538 + gui.contains("command -v fc-list") && !gui.contains("! command -v fc-list"),
539 + "the GUI-bearing server arm does not require fontconfig, so a mint that lost \
540 + the toolkit packages would report nothing and fail later at a link:\n{gui}",
477 541 );
542 + for pkg in [
543 + "default-fonts-cjk-sans",
544 + "default-fonts-other-sans",
545 + "google-noto-sans-mono-cjk-vf-fonts",
546 + ] {
547 + assert!(
548 + gui.contains(pkg),
549 + "the GUI-bearing server arm stopped denying {pkg}; carrying fontconfig is \
550 + not a reason to carry a font:\n{gui}",
551 + );
552 + }
478 553 assert!(
479 - !server.contains("fc-list ':family=Quasi"),
480 - "the server branch still asks fontconfig about the house faces, on a profile \
481 - that installs neither the faces nor fontconfig:\n{server}",
482 - );
483 - assert!(
484 - server.contains("! command -v fc-list"),
485 - "the server branch does not prove fontconfig itself is absent, so a package \
486 - dragging it back in would go unreported:\n{server}",
554 + gui.contains("emoji"),
555 + "the GUI-bearing server arm stopped denying an emoji font:\n{gui}",
487 556 );
488 557 }
489 558
@@ -509,7 +578,7 @@
509 578 std::fs::write(root.join("faces-staged").join(face), "face").expect("staged face");
510 579 }
511 580
512 - let output = run_block(&face_install_script(root), "client", &bin);
581 + let output = run_block(&face_install_script(root), "client", "tauri", &bin);
513 582 assert!(
514 583 output.status.success(),
515 584 "the client branch failed:\n{}",
@@ -538,7 +607,7 @@
538 607 std::fs::create_dir_all(root.join("faces-staged")).expect("staged faces");
539 608 std::fs::write(root.join("faces-staged/QuasiMono[wght].ttf"), "face").expect("staged face");
540 609
541 - let output = run_block(&face_install_script(root), "server", &bin);
610 + let output = run_block(&face_install_script(root), "server", "none", &bin);
542 611 assert!(
543 612 output.status.success(),
544 613 "the server branch failed:\n{}",
@@ -564,7 +633,7 @@
564 633 std::fs::create_dir_all(root.join("faces-staged")).expect("staged faces");
565 634 std::fs::create_dir_all(root.join("usr/share/fonts/quasi")).expect("an installed face dir");
566 635
567 - let output = run_block(&face_install_script(root), "server", &bin);
636 + let output = run_block(&face_install_script(root), "server", "none", &bin);
568 637 assert!(
569 638 !output.status.success(),
570 639 "a server root carrying the house faces passed the install block",
@@ -595,7 +664,12 @@
595 664 \nesac\nexit 0\n",
596 665 );
597 666
598 - let output = run_block(&block_with("dnf install -y fontconfig"), profile, &bin);
667 + let output = run_block(
668 + &block_with("dnf install -y fontconfig"),
669 + profile,
670 + "none",
671 + &bin,
672 + );
599 673 assert!(
600 674 output.status.success(),
601 675 "the fontconfig install block failed on {profile}:\n{}",
@@ -619,7 +693,12 @@
619 693 write_executable(&bin.join("dnf"), "#!/bin/sh\nexit 0\n");
620 694 write_executable(&bin.join("fc-cache"), "#!/bin/sh\nexit 0\n");
621 695
622 - let output = run_block(&block_with("dnf install -y fontconfig"), "server", &bin);
696 + let output = run_block(
697 + &block_with("dnf install -y fontconfig"),
698 + "server",
699 + "none",
700 + &bin,
701 + );
623 702 assert!(
624 703 !output.status.success(),
625 704 "a server carrying fontconfig from its base passed the install block",
@@ -639,7 +718,7 @@
639 718 let bin = fake_bin(&scratch);
640 719 rpm_tool(&bin, "bash\nsystemd\npodman\nadwaita-mono-fonts");
641 720
642 - let output = run_block(&font_guard_script(root), "server", &bin);
721 + let output = run_block(&font_guard_script(root), "server", "none", &bin);
643 722 assert!(
644 723 output.status.success(),
645 724 "the guard failed on a correctly headless root:\n{}",
@@ -647,6 +726,49 @@
647 726 );
648 727 }
649 728
729 + /// The same guard on a build host: `PROFILE=server GUI=tauri`, which is astra.
730 + ///
731 + /// Both directions, because each is a real failure. With the font tools present
732 + /// the root is correct and the guard has to pass -- asserting absence here is
733 + /// what the first astra mint hit. With them missing the toolkit packages did not
734 + /// land, and a mint that says nothing about that produces a build host that
735 + /// fails at a link months later.
736 + #[test]
737 + fn the_font_guard_reads_a_tauri_build_host_both_ways() {
738 + let scratch = Scratch::new("guard-server-tauri");
739 + let root = scratch.path();
740 + let bin = fake_bin(&scratch);
741 + rpm_tool(
742 + &bin,
743 + "bash\nsystemd\npodman\ngtk3-devel\nwebkit2gtk4.1-devel",
744 + );
745 + font_tools(&bin, &root.join("usr/share/fonts/quasi"));
746 +
747 + let output = run_block(&font_guard_script(root), "server", "tauri", &bin);
748 + assert!(
749 + output.status.success(),
750 + "the guard failed on a build host that correctly carries fontconfig and no face:\n{}",
751 + String::from_utf8_lossy(&output.stderr),
752 + );
753 +
754 + let bare = Scratch::new("guard-server-tauri-bare");
755 + let bare_root = bare.path();
756 + let bare_bin = fake_bin(&bare);
757 + rpm_tool(&bare_bin, "bash\nsystemd");
758 +
759 + let output = run_block(&font_guard_script(bare_root), "server", "tauri", &bare_bin);
760 + assert!(
761 + !output.status.success(),
762 + "the guard passed a GUI=tauri host with no fontconfig, so a mint that lost the \
763 + toolkit packages would report nothing",
764 + );
765 + let stderr = String::from_utf8_lossy(&output.stderr);
766 + assert!(
767 + stderr.contains("no fontconfig"),
768 + "the failure does not say fontconfig is what is missing:\n{stderr}",
769 + );
770 + }
771 +
650 772 /// And fails on each way that root could be wrong: fontconfig back on the
651 773 /// PATH, the faces installed, the browser's coverage installed, an emoji font.
652 774 #[test]
@@ -685,7 +807,7 @@
685 807 std::fs::create_dir_all(root.join("usr/share/fonts/quasi")).expect("faces");
686 808 }
687 809
688 - let output = run_block(&font_guard_script(root), "server", &bin);
810 + let output = run_block(&font_guard_script(root), "server", "none", &bin);
689 811 assert!(
690 812 !output.status.success(),
691 813 "the guard passed a server root with {label}",
@@ -712,7 +834,7 @@
712 834 font_tools(&bin, &fonts);
713 835 rpm_tool(&bin, "bash\ndefault-fonts-cjk-sans");
714 836
715 - let output = run_block(&font_guard_script(root), "client", &bin);
837 + let output = run_block(&font_guard_script(root), "client", "tauri", &bin);
716 838 assert!(
717 839 output.status.success(),
718 840 "the guard failed on a client root that has everything it asks for:\n{}",