Skip to main content

max / alloy

Add a rust-stage check so a SHOP_REV bump cannot break the image unnoticed The rust-build stage is the one part of the image whose inputs live in another repo. It clones shop at SHOP_REV and builds it against whatever -devel packages the dnf line installs, so shop adding a linked system library breaks the image with no edit here. That happened on 2026-08-09 with fontconfig, and the broken commit reached astra, srht and mnw before anyone built it. build/check-rust-stage.sh builds `--target rust-build` and nothing else: the toolchain layer, shop and the console, without the 7 GB runtime. Exit codes follow refresh-base-digests.sh, so "the image does not build" (1) stays distinct from "nothing could be reached to find out" (3, prints error:). On a failure it reads the log and, when a build script probed pkg-config or the linker wanted a library nobody installed, says so and names the dnf line rather than leaving a podman exit status. A build rather than a static check. The cheap alternative was a test asserting every library shop links has its -devel package on the dnf line, which needs a crate-to-package table that is not mechanical and passes whenever shop adds something the table has not learned. Measured on fw13: 6m20s cold, 0.7s when the layer cache hits. The astra sweep runs it nightly as `rust-stage`.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 17:25 UTC
Signed with PGP, not checked
Commit: f1d91604a69a258ead94404b0445f8f54534a459
Parent: baf5a7d
3 files changed, +139 insertions, -2 deletions
M Containerfile +6 -1
@@ -77,7 +77,12 @@
77 77 # Pinned by revision, not by branch. An unpinned build is a different image
78 78 # every day for the same Containerfile, and the terminal is the one component
79 79 # where "it worked yesterday" has no recovery path from inside the session.
80 - # Bump this deliberately.
80 + # Bump this deliberately, and run `build/check-rust-stage.sh` before pushing
81 + # the bump: shop links its system libraries rather than dlopening them, so a
82 + # revision can arrive needing a -devel package the dnf line above does not
83 + # install, and this file is where that has to be fixed. The astra sweep runs
84 + # the same script nightly (`rust-stage`), which is the backstop for when
85 + # nobody does; on 2026-08-09 nobody did and the break reached three remotes.
81 86 #
82 87 # `cargo install` rather than a second COPY-and-build stage: shop is a separate
83 88 # repo with its own workspace, so there is no local tree to copy and no
M docs/STACK.md +1 -1
@@ -148,7 +148,7 @@
148 148
149 149 Cost of authoring rather than adopting, stated plainly:
150 150
151 - - **No repo to ship from.** shop is packaged nowhere, so the image builds it from source at a pinned revision (`SHOP_REV` in the Containerfile). An image build needs network to fetch it, and bumping the pin is a deliberate act rather than something `dnf update` does.
151 + - **No repo to ship from.** shop is packaged nowhere, so the image builds it from source at a pinned revision (`SHOP_REV` in the Containerfile). An image build needs network to fetch it, and bumping the pin is a deliberate act rather than something `dnf update` does. It is also an act that can break the image on its own: shop links its system libraries, so a revision can arrive wanting a `-devel` package the Containerfile does not install. `build/check-rust-stage.sh` builds the builder stage and nothing else, which is the check to run after a bump; the astra sweep runs it nightly.
152 152 - **It is new.** Written in 2026-07. The daily-drive bar it had to clear was selection and clipboard plus real key handling, both landed 2026-07-31, and scrollback with the wheel and Shift+Page landed the same day. Still absent at the pinned revision: scrollback search; reflow, so a resize clips scrollback to the new width rather than rewrapping it; sixel, which the kitty protocol covers the same ground as and which shop deliberately does not claim in its DA1 reply; OSC 8 hyperlinks.
153 153 - **The complexity was the objection, and it was a fair one.** Terminals are among the most complex userland software: ANSI, terminfo, graphics protocols, IME, OSC, performance under load. This section previously rejected writing one on exactly that ground. The scope was taken on deliberately, with a stated bar and a fallback, rather than by deciding the objection was wrong.
154 154
@@ -1,0 +1,132 @@
1 + #!/usr/bin/env bash
2 + #
3 + # check-rust-stage.sh — build the Containerfile's `rust-build` stage and
4 + # nothing else, to prove the image still compiles.
5 + #
6 + # The stage that breaks silently. It clones shop at ${SHOP_REV} and builds it
7 + # with the console, so it is the one part of the image whose inputs live in
8 + # another repo and move without any edit here. On 2026-08-09 shop@53551eb added
9 + # `fontconfig = "0.11"`, yeslogic-fontconfig-sys links libfontconfig through
10 + # pkg-config rather than dlopening it, and the stage installed
11 + # `wayland-devel libxkbcommon-devel pkgconf` and nothing else. `cargo install`
12 + # died on both profiles. The broken commit reached astra, srht and mnw before
13 + # anyone built it, because a SHOP_REV bump is a one-line edit that looks like
14 + # it cannot fail.
15 + #
16 + # Why a build rather than a static check. The obvious cheap guard is a test
17 + # that reads shop's dependencies and asserts every one has its -devel package
18 + # on the dnf line. It was rejected: it needs a crate-name to package-name table
19 + # that is not mechanical (yeslogic-fontconfig-sys -> fontconfig-devel), and it
20 + # passes whenever shop adds a dependency the table has not learned — a guard
21 + # whose correctness depends on the same discipline that failed in the first
22 + # place. Building the stage proves the property directly and needs to know
23 + # nothing about what shop links. It also catches every other way this stage can
24 + # break, rather than the one way it broke last.
25 + #
26 + # Only the builder. `--target rust-build` stops before the bootc stage, so this
27 + # skips the 7 GB runtime, the fonts, the browser and the profile split. What is
28 + # left is the toolchain layer, shop, and the console.
29 + #
30 + # Usage:
31 + # build/check-rust-stage.sh # build it; say what happened
32 + #
33 + # Run it after moving SHOP_REV, before pushing. The astra sweep runs it nightly
34 + # (`rust-stage` in sweep.toml), which is the backstop for when nobody does.
35 + #
36 + # Exit codes follow refresh-base-digests.sh, for the same reason:
37 + #
38 + # 0 the stage builds.
39 + # 1 the stage does not build. The image is unbuildable and, since the
40 + # install path is "build it yourself" (docs/IMAGE.md), so is Alloy.
41 + # 3 something about the run, not about the image: no podman, no network, a
42 + # registry or a git remote nobody could reach. Prints `error:`, which the
43 + # sweep turns into an error cell rather than a finding.
44 + #
45 + # Rootless on purpose, unlike build-image.sh, which needs `sudo podman` for the
46 + # bootc conversion. Nothing here leaves the builder stage, so the root store is
47 + # not involved and a check should not ask for privilege it does not need. The
48 + # consequence worth knowing: this populates the *rootless* layer cache, so it
49 + # does not warm a subsequent `build/build-image.sh`.
50 +
51 + set -Eeuo pipefail
52 +
53 + REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
54 + CONTAINERFILE="$REPO_ROOT/Containerfile"
55 +
56 + # Replaced rather than accumulated. An untagged `--target` build leaves a
57 + # dangling image behind every night, and a nightly cell is exactly the thing
58 + # that fills a disk quietly.
59 + IMAGE="localhost/alloy-rust-stage:check"
60 +
61 + # Exit 3, never 1: 1 is reserved for "the image does not build", and a caller
62 + # that cannot tell a broken build from a broken network learns the wrong thing
63 + # from both.
64 + die() { printf 'error: %s\n' "$*" >&2; exit 3; }
65 +
66 + trap 'rc=$?; [ "$rc" -eq 3 ] && exit 3; die "unexpected failure at line ${LINENO}"' ERR
67 +
68 + case "${1:-}" in
69 + -h|--help) sed -n '2,48p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
70 + "") ;;
71 + *) die "unknown argument: $1 (see --help)" ;;
72 + esac
73 +
74 + command -v podman >/dev/null || die "podman not found"
75 + [ -f "$CONTAINERFILE" ] || die "no Containerfile at $CONTAINERFILE"
76 +
77 + # Reported whatever happens, so a red cell names the revision it was red about
78 + # without anyone opening the Containerfile at that commit.
79 + SHOP_REV="$(grep -oE '^ARG SHOP_REV=[0-9a-f]{7,40}' "$CONTAINERFILE" | head -1 | cut -d= -f2 || true)"
80 + [ -n "$SHOP_REV" ] || die "no ARG SHOP_REV line in $CONTAINERFILE"
81 + printf 'building the rust-build stage; SHOP_REV=%s\n' "$SHOP_REV"
82 +
83 + log="$(mktemp -t alloy-rust-stage.XXXXXX.log)"
84 + trap 'rm -f "$log"' EXIT
85 +
86 + status=0
87 + podman build \
88 + --target rust-build \
89 + --pull=missing \
90 + -t "$IMAGE" \
91 + -f "$CONTAINERFILE" \
92 + "$REPO_ROOT" 2>&1 | tee "$log" || status="${PIPESTATUS[0]}"
93 +
94 + if [ "$status" -eq 0 ]; then
95 + echo
96 + echo "rust-build stage builds at SHOP_REV ${SHOP_REV}"
97 + exit 0
98 + fi
99 +
100 + # Failed. Which kind of failed is the whole value of this script: a red cell
101 + # saying "podman exited 1" costs a person the build they are trying to avoid.
102 +
103 + # The evidence-hole cases first. Each of these is something outside the image:
104 + # the registry serving the pinned base, or the git remote serving shop. The
105 + # ls-remote wording is the Containerfile's own guard talking (it prints
106 + # `cannot reach ... to check SHOP_REV`), and the zero-refs case is the one that
107 + # already happened once, recorded there against GoingsOn problem 49732815.
108 + if grep -qEi 'cannot reach .* to check SHOP_REV|serves zero refs|no such host|temporary failure in name resolution|connection refused|i/o timeout|TLS handshake timeout|error pinging container registry' "$log"; then
109 + die "the build could not reach something it needs (registry or git remote), so this says nothing about the image"
110 + fi
111 +
112 + echo
113 + echo "the rust-build stage does not build at SHOP_REV ${SHOP_REV}."
114 +
115 + # The class this script exists for, named explicitly when it recurs. Anything
116 + # reaching a linker or a pkg-config probe has got past the network and past
117 + # cargo's resolver, so it is a missing header on the dnf line until proven
118 + # otherwise.
119 + if grep -qEi 'could not find .* pkg-config|pkg-config .* not found|No package .* found|PKG_CONFIG_PATH|cannot find -l[a-z]' "$log"; then
120 + cat <<'EOF'
121 +
122 + It looks like a missing system library rather than a code error: a build script
123 + probed pkg-config, or the linker wanted a library nobody installed.
124 +
125 + shop links its system libraries rather than dlopening them, so a SHOP_REV move
126 + can add one. Its README's Building section is the list at any revision. Add the
127 + Fedora -devel package to the dnf line in the rust-build stage of the
128 + Containerfile, above the shop install.
129 + EOF
130 + fi
131 +
132 + exit 1