Skip to main content

max / alloy

44.7 KB · 579 lines History Blame Raw
1 # Image composition
2
3 How Alloy is built and delivered. bootc + Containerfile, source on `makenot.work`, built locally and natively, and **no image published anywhere, ever** (see "Registry" below). Companion to [STACK.md]STACK.md.
4
5 ## Landscape
6
7 Silverblue and its atomic siblings don't have a mutable root filesystem. The OS is an immutable commit, a tree of files that swaps atomically at boot. Users can't `dnf install` into a running system; they either layer packages locally (rebuilding their image) or pull a pre-built image.
8
9 For a distro builder, "image composition" is the question of how you produce that immutable commit and how users receive it.
10
11 ## Options considered
12
13 ### rpm-ostree compose (traditional)
14
15 Classical Silverblue build. Treefile in YAML declares packages, kernel arguments, systemd presets. `rpm-ostree compose tree` produces an ostree commit, pushed to an ostree repository. Users pull via `rpm-ostree upgrade`.
16
17 **Cost:** you host an ostree repository (real infrastructure: nginx serving `/var/ostree` or equivalent). Legacy-shaped; Fedora itself is migrating away.
18
19 ### bootc + Containerfile (modern, where Fedora is going)
20
21 The OS *is* an OCI container image. Distributed via any OCI registry. Users pull with `bootc switch <registry-url>` and reboot. Build is `podman build` from a Containerfile, the same as building any Docker image.
22
23 **Cost:** pre-1.0, occasional breaking changes as bootc matures. Alloy accepts drift maintenance the same way it does for nushell config and userChrome.css.
24
25 ### ublue-downstream (rejected)
26
27 Universal Blue's convention is exactly the bootc + Containerfile pattern above, using `FROM ghcr.io/ublue-os/main`. Battle-tested. But being downstream of ublue means inheriting ublue's opinions and coupling to their release cadence, which [STACK.md's Base section]STACK.md#base explicitly rejects.
28
29 **The nuance:** Alloy takes the ublue-style *convention* (a Containerfile over a bootc base) without ublue's base image: `FROM registry.fedoraproject.org/fedora-bootc:43`, not `FROM ghcr.io/ublue-os/main`. That is what "alongside ublue, not downstream" resolves to in practice.
30
31 The convention does not include an OCI registry or a CI-driven build, and that is where Alloy parts company with ublue: ublue's model is a published image somebody else builds for you, and Alloy's is source you build yourself. quay.io appears in this repo in exactly two places, both `FROM` lines pulling Fedora's own bases, and nothing is ever pushed there.
32
33 ### "Install Silverblue and run our script" (non-option)
34
35 Zero build infrastructure. But users get a mutated Silverblue with an Alloy veneer, not shipped as "Alloy." Manifesto commitment breaks. Rejected.
36
37 ## Pick: bootc + Containerfile
38
39 The tool choice. Rationale summarized: aligns with where Fedora is going; build tooling is `podman build`; distribution is any OCI registry (free); Containerfile is a single-file OS specification.
40
41 ## Delivery stack
42
43 ### Source: `makenot.work/git/max/alloy`
44
45 Alloy publishes its source on Make Creative's own platform, for the reason the project holds elsewhere: independence from big-tech-monopoly infrastructure at the layer where users install software, and hosting our own source rather than renting someone's. `astra` is the private mirror and `srht` is a backup remote, not the public face. See `~/Code/CLAUDE.md`, "What each remote is for".
46
47 The source is the distributed artifact, so this URL is not a courtesy listing, it is where Alloy comes from.
48
49 ### CI: none. Builds are local and native.
50
51 Alloy has no hosted CI.
52
53 The image is built by `build/build-image.sh` (disk images, via bootc-image-builder) and `build/build-iso.sh` (the installer ISO) on fw13, against rootful podman. That is the whole build story today, and it is a deliberate consequence of two standing rules rather than an accident: builds are native per architecture and nothing is cross-compiled, and app binaries are never built on the production host.
54
55 **Both architectures build an installer, not just x86_64.** `build/make-iso.sh` takes its architecture from `uname -m` and maps it to a GRUB target and a removable-media binary name: `x86_64-efi`/`BOOTX64.EFI`, `arm64-efi`/`BOOTAA64.EFI`. Anything else is refused by name rather than defaulted. The builder installs the matching `grub2-efi-*-modules` package the same way, and the script fails early naming that package if the modules are not there, rather than several minutes into a squashfs. `--arch` on `build/build-iso.sh` overrides the detection; it exercises the GRUB half from the other architecture and does not produce a bootable medium, because everything around the boot chain is still the native image.
56
57 Arm is an installer target and not a dd-the-image special case. The installer is the only place LUKS, the recovery phrase, the ssh key and the hostname are collected, and a written disk image collects none of them, so an arm machine installed by that route would be installed by a path no user of Alloy takes. `alloy install` has never been run on aarch64, so a medium existing is necessary and not sufficient.
58
59 An always-on x86_64 builder is wanted for development. It stands between nobody and running Alloy: nothing has to be built centrally for a stranger to install Alloy, because the stranger does the building. astra is always-on but aarch64, fw13 is x86_64 and sleeps aggressively, and the Fedora build host meant to replace Pop!_OS on fw13 is where that resolves whenever it does.
60
61 Nothing validates the Containerfile except someone remembering to build it. That gap is real and is independent of distribution.
62
63 ### Registry: none, and this is settled rather than deferred
64
65 **Nothing is published, and nothing will be.** Distribution is the builder, not the artifact. Users take the source, configure it, build the ISO on their own machine, and write it to a medium. `quay.io/alloy/alloy` is not a reserved future address, and the hosts that would otherwise be weighed against it (ghcr.io, Docker Hub, self-hosted) are moot rather than rejected.
66
67 Three things follow, and they are the point rather than side effects:
68
69 - **No signing story to build.** cosign signs images in a registry; with no registry there is nothing to sign, no public key to publish, and no private key needing a home. What a user verifies instead is the source, which is what git already gives them.
70 - **No source-availability obligation.** The image carries a large number of GPL and LGPL projects, and redistributing their binaries would put the GPL-2 §3 / GPL-3 §6 offer on Make Creative. Ship no binaries and it never attaches: users pull RPMs from Fedora and Terra, who discharge their own.
71 - **No hosting, no bandwidth, no publish matrix per architecture.**
72
73 The accepted cost is that a build takes real time and disk on the user's own machine, and that there is no path to an Alloy ISO from macOS or Windows at all.
74
75 ### Updates: only where updates can be fetched
76
77 An installed machine does not reach out unless the medium it was installed from named an update target, which only a development build does. The installer enables `bootc-fetch-apply-updates.timer` when `--update-target` was given and leaves it disabled otherwise, so a locally-built install never polls anything.
78
79 The eventual home for updates a stranger's machine can reach is a service over synckit/MNW, not a public registry.
80
81 **Until that exists, updating means rebuilding — not reinstalling.** A machine adopts a locally built image in place:
82
83 bootc switch --transport containers-storage localhost/alloy:local
84
85 That is A/B staged with rollback, the same mechanism a registry-fed update would use, with the image coming off local container storage instead of the network. bootc 1.16.3 accepts registry, oci, oci-archive, docker-daemon and containers-storage.
86
87 That is the same machine adopting its own build. For a *different* machine, and above all for a VM under `build/vmtest`, the loop that matters is `build/dev-push.sh`: it runs a registry on the dev box, pushes `localhost/alloy:local` into it, and prints the `bootc switch` line to run on the target. A registry rather than an archive because the target then pulls only the layers it lacks, so a config-only rebuild is megabytes rather than three gigabytes down the wire. The push out of local storage is a flat ~20s either way: skopeo recompresses every layer because containers-storage records no compression metadata, so it cannot know a blob's digest before it has made it. It is a development tool that lives and dies with a `--stop`, and it changes nothing about the position above: no image is published anywhere.
88
89 The reason it exists is the measured shape of the loop. A config-only change is fifteen seconds of `podman build` and then four minutes of ISO assembly, and the ISO is installer media nobody is going to install from when the question is whether a sway binding works. Building one to see a config change is the single most expensive habit available here.
90
91 So "we publish nothing" costs a build, not a machine. What a user gives up against a published image is the time to build it, and what they gain is that the thing they boot is the thing they configured. Updating is a rebuild, not a reinstall.
92
93 ## Layer structure
94
95 The Containerfile at the repo root (see `Containerfile`) sketches:
96
97 1. **Base:** `FROM registry.fedoraproject.org/fedora-bootc:43`. Fedora's own registry rather than quay, and it is a retention choice: quay garbage-collects a digest the moment its tag moves off it, three times taking the pinned base with it and leaving the whole install path unbuildable. Every digest quay had dropped was still served at registry.fedoraproject.org when that was measured, and the tag resolves to the same index digest at both, so it is the same content with a longer memory. The mirror that makes retention ours is still owed (`build/refresh-base-digests.sh` says why).
98 2. **Third-party repos:** Tailscale, any COPRs Alloy depends on for packages not in Fedora main.
99 3. **Package additions:** the full Alloy stack from [STACK.md]STACK.md: compositor, bar, launcher, notifications, terminal, editor, shell, viewers, utilities, continuity daemons, fonts, themes.
100 4. **Package removals:** stock Silverblue desktop pieces Alloy replaces (gnome-shell, gdm; the latter gated on the greeter pick).
101 5. **Config tree:** the tree at `etc/skel/.config/*` (new-user defaults), `etc/*` (system-wide), and `usr/*`, including `usr/share/polkit-1/rules.d/*` (which system settings the console may change without a prompt) in the repo maps 1:1 into the image. The polkit rule is asserted at build time against the actions the image actually defines, since a grant naming a renamed action is inert and silent about it.
102 6. **Rendered tree:** everything in the image that carries a color is not in the repo as a finished file. `templates/` holds it with the palette left as tokens, and `skelgen` renders it against the two Akari themes into a second tree that mirrors `/` the same way the config tree does. Themed skeleton files render twice: the light one lands at `etc/skel/<rel>`, the dark one at `usr/share/alloy/skel-night/<rel>`, and `alloy theme apply` copies whichever the user's mode file names into `$HOME` at login. The build asserts the two trees are a bijection and that they do not overlap the repo's own `etc/skel`, because a themed file that quietly loses its dark render leaves a light sway border on a dark desktop and nothing else.
103 7. **Systemd presets:** which services are enabled by default (syncthing off by default, gammastep off until enrolled, alloy-hinged conditionally on FW12, etc.).
104 8. **Branding:** os-release, plymouth splash. The build stamps the image's build number into os-release here; see [Version fields]#version-fields. It stamps the profile in the same way, rewriting the committed `VARIANT="<profile>"` and `VARIANT_ID=<profile>` placeholders to `Client`/`client` or `Server`/`server`, and failing if either placeholder survives. Only the profile: the tag variants (`firewall-server`, `usbgate-server`) are names chosen by whoever runs the build, and the Containerfile is never told which one it is making.
105 9. **Validation:** `bootc container lint --fatal-warnings` runs at build, and its warnings are fatal rather than advisory. Two of its checks constrain what the image may carry, and both are about the upgrade path rather than about tidiness. `var-tmpfiles` wants every directory in `/var` declared in a `tmpfiles.d` file, and `sysusers` wants every account in `/etc/passwd` declared in a `sysusers.d` file, because `/var` and `/etc` are per-machine: `bootc install` copies the image's copies into the stateroot, while an upgrade re-syncs `/usr` and leaves both alone. Content that exists only because a package's `%post` or a `useradd` ran during a build therefore reaches a fresh install and never reaches a machine that upgrades into the image. Alloy's declarations are `usr/lib/tmpfiles.d/50-alloy-var.conf` (plus a `-client` half deleted on `server`, whose lines name users only that profile has) and `usr/lib/sysusers.d/50-alloy-greeter.conf`. **A package added here that drops a new `/var` directory or a new account fails the build until it gets a line in one of them.** The build host's own leavings are deleted rather than declared, in the sweep immediately above the lint: dnf's caches and log, the ldconfig aux-cache, the appstream catalogs, authselect's checksum, and everything under `/run`.
106
107 **A package added here gets a line in `crates/alloy/credits.toml`.** The installer's last screen names the projects Alloy ships and their licenses, off a hand-curated manifest rather than a generated closure, so nothing adds itself. The manifest is embedded in the console binary with `include_str!`, which means the page cannot go missing on installer media or a read-only deployment and also means a manifest edit needs a rebuild. Its own header says which license to record: for anything packaged out of Rust or Go, Fedora's `%{LICENSE}` is the whole vendored closure rather than the project's own terms, so read upstream's LICENSE for those and use `rpm -q --qf '%{LICENSE}'` only for the C packages.
108
109 The browser ships as an RPM baked into the image: one code path, no first-boot delay, and no first-run network dependency. It is Firefox, from Fedora's own repos, and it carries exactly one file of Alloy configuration (`/etc/firefox/pref/alloy.js`, anti-features removed and one hidden control restored; see [STACK.md]STACK.md#browser). The `flatpak` client is included so users can pull sandboxed-only apps on demand post-install; no Flatpaks and no remote are provisioned at build or first-boot time, because which catalog to trust is the user's call.
110
111 ## Size
112
113 Measured against `localhost/alloy:clip-client`, the client profile with
114 `BROWSER=helium LANGS=rust,go TRIM=unused`. It was 6.19 GB as an image and 5.26 GB
115 deployed, and the 0.9 GB between those two numbers is the subject of half this section.
116
117 Every figure below is from that build, so they describe an image built with Go in it and
118 with a browser this tree no longer offers. `LANGS` defaults to empty, so a stock mint
119 carries no toolchain and does not build the 778 MB language row at all, and Firefox's
120 layer is unmeasured. The numbers are left as measured rather than adjusted on paper,
121 because an adjusted number is a guess wearing a measurement's clothes. Re-measure the
122 browser layer on the next full build.
123
124 Two sizes, and they answer different questions. **Image size** is the sum of the layers:
125 what a registry stores and what `bootc upgrade` moves. **Deployed size** is the final
126 filesystem: what the disk holds and what the ISO squashes. A file written in one layer
127 and deleted in a later one is gone from the second number and permanent in the first,
128 because a delete across a layer boundary is a whiteout rather than a refund. Most of
129 what looked like waste here was that one mechanic.
130
131 ### Layers over 100 MB
132
133 | Size | Layer | Why it is that size |
134 |------|-------|---------------------|
135 | 1.44 GB | sway, `xdg-desktop-portal{,-gtk,-wlr}`, the session | The product. This is the desktop. |
136 | 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. `LANGS` is empty by default, so a stock image does not build this layer at all. Argued at `ARG LANGS`. |
137 | 674 MB | the browser | Measured against Helium; Firefox's layer is unmeasured. |
138 | 663 MB | Base package list | The stack, per [STACK.md]STACK.md. |
139 | 170 MB | initramfs + `rpmostree-unpackaged-content` | Bootable image. |
140 | 147 MB | cups + cups-filters | Printing, client profile only; the server profile presets it off. |
141 | 126 MB | `python3-botocore` (base) | Arrives in fedora-bootc. `TRIM=unused` removes it from the filesystem and cannot remove it from the image. |
142 | 113 MB | `kernel-modules` | Bootable image. |
143 | 107 MB | `nvidia-gpu-firmware` | Deliberate, and asserted: the trim block refuses to run if firmware is missing, because a medium built here cannot know what hardware will boot it. |
144 | 90 MB | `kernel-modules-core` | Bootable image. |
145
146 ### Packages that look wrong and are not
147
148 - **`helix-parsers`, 185 MB.** Named explicitly rather than losing syntax highlighting.
149 - **`rust-std-static`, 166 MB.** The name suggests static-musl targets nothing here
150 builds. It is the standard library: `rust` carries a versioned hard requirement on it,
151 and removing it takes `rust` and `cargo` with it. Not separable from `LANGS=rust`, and
152 absent entirely from a mint that did not ask for rust.
153 - **`llvm-libs`, 139 MB.** `rpm -q --whatrequires llvm-libs` returns nothing, which is
154 what makes it look orphaned, and the query is the thing that is wrong: dependents
155 require the soname. `libLLVM.so.21.1()(64bit)` is required by `mesa-dri-drivers`,
156 `mesa-vulkan-drivers` and `rust`. Load-bearing three times over.
157 - **`flite` 21 MB and `lpcnetfreedv` 15 MB.** A speech synthesiser and a digital voice
158 codec for amateur radio, in an image with no use for either. Neither was chosen and
159 neither can be removed. `libavcodec-free` carries a hard soname requirement on
160 `libcodec2.so.1.2`, which pulls codec2, which pulls lpcnetfreedv; `libavfilter-free`
161 requires `libflite.so.1` and five voice libraries for its text-to-speech filter. mpv
162 links both libraries directly, `codec2` and `flite` are the only packages providing
163 those sonames, and `ffmpeg-free` is the only ffmpeg in the configured repos, so there
164 is no lighter combination to switch to. Dropping 36 MB here means
165 building ffmpeg ourselves, which is not worth 36 MB.
166 - **`mesa-vulkan-drivers`, 153 MB.** Twelve ICDs, no rpm dependents at all, because the
167 Vulkan loader opens one by dlopen off a JSON manifest. The x86_64 ones are load-bearing:
168 `intel` (21 MB), `intel_hasvk` (17 MB), `radeon` (16 MB), `nouveau` (13 MB), `lvp` (12 MB,
169 llvmpipe, the software fallback), `virtio` (1 MB, the VM path the QEMU punch list runs on).
170 The other six are not, and are the one measured cut in this section that has NOT been
171 taken — see below.
172
173 ### Measured and not taken: the ARM-SoC Vulkan drivers, 67 MB
174
175 `mesa-vulkan-drivers` ships `panfrost` (Mali), `freedreno` (Adreno), `asahi` (Apple),
176 `powervr`, `broadcom` (VideoCore) and `dzn` (the Direct3D-to-Vulkan translation layer, for
177 Windows). This image is x86_64 and no x86_64 machine has any of those GPUs, so the 67 MB
178 is unreachable rather than merely unused.
179
180 It is left in because it is a stack decision rather than an accident, and it differs from
181 the cursor prune in one way that matters: cursor themes are data and these are drivers, so
182 pruning files out of the package puts `rpm -V` permanently in disagreement with the image
183 over a set that Fedora may re-partition at any release. The firmware reasoning does not
184 apply — that is about hardware the build cannot predict, and a Mali GPU on an x86_64 board
185 is not unpredictable — so if this is taken, take it as a prune inside the package layer with
186 the kept list asserted, the same shape as the cursor themes.
187
188 ### Two structural costs, neither attributable to any package
189
190 **The rpmdb rewrite, ~495 MB.** `/usr/share/rpm/rpmdb.sqlite` grows from 32 MB to 56 MB
191 over the build, and every `dnf` layer rewrites it whole. Twelve layers in this image carry
192 a full copy, 551 MB of which the last 56 MB is the live one. It shows up in no per-package
193 accounting and is roughly 8% of the image. The fix is fewer dnf layers, which trades
194 against the block structure this file is organised by and against build caching, so it is
195 recorded rather than taken.
196
197 **Removal layers cost and refund nothing.** `dnf remove` runs twice near the end, 61.7 MB
198 and 61.5 MB, and both are rpmdb plus whiteouts. They shrink the deployed filesystem and
199 grow the image. That is the right trade for `TRIM=unused` (botocore, toolbox, the
200 qemu-user-static set are ~290 MB off the disk) and worth knowing before anyone tries to
201 shrink the image by removing more.
202
203 ### What is cut from the image
204
205 - **Repo metadata, ~200 MB.** `terra-release` is 674 bytes and its layer was 238 MB of
206 `/var/cache/libdnf5`, cleaned by a later layer and therefore never reclaimed. `dnf clean
207 all` moved into the layer that fills it. Measured on a three-layer chain: 272 MB before,
208 114 MB after, and the next dnf line pays nothing extra because it re-downloads and
209 cleans within its own layer.
210 - **Thirteen cursor themes, 159 MB.** `bibata-cursor-theme` installs fourteen and skel
211 names one. Fedora ships no per-variant subpackage, so the package block prunes in place
212 and an assertion after the skel copy reads the name back out of skel and fails if the
213 directory it asks for is gone.
214 - **Translations and documentation, ~263 MB.** `%_install_langs en:en_US` and
215 `tsflags=nodocs`, set in the first layer of the final stage above every `dnf install`
216 in it, so nothing lands and nothing has to be pruned back out. About 170 MB of
217 translations and about 92 MB of documentation. Measured rather than argued: gtk3,
218 gtk4, helix and nushell built both ways grew `/usr/share/locale` by 112 MB without
219 these and by 0.5 MB with them. The ~160 MB the base carries in before the macro line
220 runs is out of reach.
221
222 This is the one cut with a user-visible consequence, and it is decided rather than
223 accidental. **A default install has no man pages** for anything installed after that
224 line, because Fedora marks man pages `%doc` and rpm offers no separate switch. The
225 console carries its own help and `docs/manual` exists; `TRIM=keep` builds an image
226 with both the translations and the documentation. Licence files are unaffected — rpm
227 treats `%license` separately from `%doc`, and the build asserts `/usr/share/licenses`
228 is still populated, because `crates/alloy/credits.toml` is curated against what the
229 image ships.
230
231 Prefer this shape over a prune wherever both would work: the files are never
232 installed and the rpm database knows it, so `rpm -V` still agrees with the image. The
233 cursor prune above is the counter-example, with the database declaring 188 MB against
234 27 MB on disk by design.
235
236 - **Recommends in the client block, 99.3 MB.** The layer behind the 1.44 GB desktop
237 entry was the last `dnf install` in the file not passing
238 `--setopt=install_weak_deps=False`. It passes it now, with the load-bearing
239 recommends named back explicitly: `sway-systemd` (which starts the whole systemd
240 user session), `xdg-utils` and its database, `mesa-vulkan-drivers`,
241 `mesa-va-drivers`, `yt-dlp`, `upower`, PipeWire's ALSA and JACK shims, the camera
242 path, and the CJK monospace face. Measured by resolving the block both ways against
243 the same repos: 504 packages down to 448, and nothing new pulled in.
244
245 Two things the measurement caught that reading the list would not. Taking the flag
246 naively **replaces PipeWire's JACK shim with the real JACK daemon**, because
247 something in the set requires the interface either way, so six packages appear in
248 the transaction the flag was meant to shrink. And the two `default-fonts-*`
249 metapackages look like casualties and are not: they *require* 62 per-language
250 metapackages which *require* their Noto faces, so every script survives. What drops
251 there is seventeen supplementary Thai display faces, 6.9 MB, which are alternative
252 faces for a covered script rather than coverage.
253
254 None of this is a race to a number. Two of the four largest layers are the desktop and the
255 browser and both are the product; the point is that every large thing in this image is
256 either explained above or gone.
257
258 ## Per-machine mint recipes
259
260 The dials that decide what a machine can do all default to the smallest thing
261 that boots. `LANGS` defaults to empty, so a default mint carries no language
262 toolchain and no C compiler; `DB` defaults to `none`; `PROFILE` defaults to
263 `client`. That is right for an image whose job is to run software, and it is a
264 trap for the three machines that build it: a forgotten flag produces a build
265 host that boots, looks healthy, and cannot compile.
266
267 So each machine's dials live in `build/hosts/<name>.env`, and both builders take
268 `--host <name>`:
269
270 build/build-image.sh --host fw13
271 build/build-iso.sh --host astra
272
273 Format is `KEY=VALUE`, one per line, with `#` comments. Every key must name an
274 `ARG` the Containerfile declares, checked against the Containerfile itself when
275 the recipe is read, because podman drops an unknown build arg silently and a
276 typo would mint exactly the image the file exists to prevent.
277
278 Three keys are reserved, because what each names is not something the
279 Containerfile can be told. All three are read by `build-iso.sh` and ignored by
280 `build-image.sh`.
281
282 | key | what |
283 |-----|------|
284 | `ARCH` | the medium's architecture. Not a cross-build: a machine builds its own medium. |
285 | `NVIDIA` | `yes` installs the derived `alloy-nvidia` image rather than Alloy itself, since the module compiles against one exact kernel. |
286 | `UPDATE_TARGET` | where `bootc upgrade` fetches from on a machine installed from this medium. |
287
288 `UPDATE_TARGET` is the one whose absence is expensive and silent. Without it the
289 installer writes the compiled-in public registry, which `install/image.rs` calls
290 a deliberate dead end, and `updates_scheduled()` leaves
291 `bootc-fetch-apply-updates.timer` disabled because it reads the same absent
292 kernel parameter. So a machine minted without it cannot take a fix at all
293 without a `bootc switch` typed at the machine or a second medium, at the moment
294 it is newest. It was reachable only as `--update-target`, which is a thing to
295 remember per mint; as a recipe key it is the machine's own answer, kept.
296
297 Recipe values go in ahead of anything typed on the command line, so an explicit
298 `--build-arg` overrides the recipe rather than racing it.
299
300 What the three recipes say today: fw13 takes `LANGS=rust,js`, `DB=postgres16`
301 and `GUI=tauri` for the build-host role; astra takes those plus `PROFILE=server`,
302 `ARCH=aarch64` and `NVIDIA=yes`; fw12 sets no dials at all and carries only
303 identity and answers, below.
304
305 fw13 and fw12 both take `UPDATE_TARGET=fw13:5000/alloy:local`, the tag
306 `build/dev-push.sh` publishes: fw13 builds its own image and serves it to
307 itself, and the tag carries the host name so one registry also feeds fw12 over
308 the LAN. astra takes none, and no medium ever names a fleet update source,
309 because there is not one: an installed machine updates by applying instructions
310 to itself rather than by pulling an image. `UPDATE_TARGET` is a dev-loop key.
311
312 ## Preflight
313
314 `build/preflight.sh <host>` answers, before any mint, what a mint would otherwise
315 answer slowly. **Both builders run it themselves**, so this is the explicit form
316 rather than the usual one:
317
318 ```sh
319 build/preflight.sh fw13 # everything
320 build/preflight.sh fw13 --fast # the instant checks only, no probe
321 ```
322
323 Four checks:
324
325 | check | how | catches |
326 |---|---|---|
327 | `dials` | mirrors the Containerfile's own validator | a recipe that cannot pass step 20 |
328 | `requires` | `command -v`, `pkg-config`, `find` inside a probe image | a role's capability the mint does not deliver |
329 | `var-payload` | `bootc container lint` on that probe | undeclared `/var` content, which fails at step 101 of 103 |
330 | `guards` | grep, on the host | a known workaround being dropped |
331
332 `build/hosts/<name>.requires` is the second half of a recipe. The `.env` says
333 which dials to set; the `.requires` says what has to exist on the other side.
334 That distinction is the one four thrown-away mints on 2026-09-04 turned on: every
335 dial was set correctly and the image still could not build a Tauri app or restore
336 a production dump, because nothing anywhere said fw13 had to be able to do either.
337
338 The last two checks run against a **probe image** -- this mint's package set plus
339 the tmpfiles files it would ship -- rather than against a prediction. Predicting
340 was tried first and was wrong twice in the same way: `var-payload` derived bootc's
341 rule from the symptom and flagged paths the real build accepts, and `requires`
342 compared a capability's provider against the packages the Containerfile names, so
343 `bin:git` failed because `/usr/bin/git` comes from `git-core`. Building the
344 smallest image that can be asked directly removes both. It is a build, which is
345 what the script exists to avoid, but it is one dnf transaction rather than 103
346 steps, and it is right rather than close.
347
348 ### The gate
349
350 `build/build-iso.sh` and `build/build-image.sh` both run the preflight before
351 they mint anything, and refuse on a finding. `build/preflight-gate.sh` holds that
352 policy once, so the two builders cannot drift apart on it.
353
354 It was deliberately not a gate on day one: a gate that fires wrongly on its first
355 day is worse than no gate, and the preflight had never been run in anger. It has
356 since caught four things nobody predicted, including a recipe combination the
357 Containerfile's validator refuses on a machine whose medium had never been built.
358
359 **Only findings about the medium refuse a build.** `dials`, `requires` and
360 `var-payload` describe the image, and an image with one of them outstanding is
361 wrong wherever it is built. `guards` describes the machine you are standing on --
362 it reads sibling repos under `~/Code` that no image contains -- so a stale
363 goingson clone predicts a failed AppImage here and says nothing about the medium.
364 That warns loudly and the build goes on. Refusing a mint because a repo next door
365 is behind would make the gate a thing people route around, and routing around it
366 is how the four findings above would have been missed.
367
368 The two kinds are two exit codes, which is what makes the split possible:
369
370 | code | means |
371 |---|---|
372 | 0 | clean |
373 | 1 | findings about the image; do not build |
374 | 2 | usage: no host named, or no recipe by that name |
375 | 4 | findings about this host alone; the medium is fine |
376
377 **How much of the preflight a mint runs** is decided the same way. The probe is
378 one dnf transaction: seconds on the host's own architecture, minutes under
379 emulation (measured 2026-09-04, the astra probe took minutes on fw13 and seconds
380 on astra). So the gate runs the full preflight when the medium's architecture is
381 this machine's and `--fast` when it is not, and says which it chose. A mint with
382 no `--host` is not gated, because there is no recipe to check; that is printed
383 rather than silent.
384
385 `--no-preflight` skips it, for deliberately building a known-incomplete image --
386 bisecting a Containerfile change, or reproducing a defect the preflight would
387 refuse. It is not a way past a finding you would rather not read.
388
389 **What the preflight cannot see**, stated so a clean run is not read as more than
390 it is:
391
392 - `/var` content that arrives from something other than a package. The
393 Containerfile copies `etc/` and `usr/` and runs its own steps.
394 - Packages the real build takes from a repo the probe does not configure.
395 Tailscale is the measured case: a bare base resolves the name to a different
396 build with a different payload, so it is excluded and reported rather than
397 installed.
398 - COPR and Terra packages, which do not resolve in a bare base at all.
399 - Runtime interactions. linuxdeploy bundling a binutils that cannot read Fedora
400 43's `.relr.dyn` sections is the example, and no static check finds it. What
401 `guards` catches is that workaround being *lost*, which is how it was.
402 - Anything a role needs that lives in `$HOME` rather than in the image. The
403 publish role is the measured case: Fedora's `rust` carries neither rustfmt nor
404 clippy and the image declines rustup, so `cargo fmt` and `cargo clippy` are
405 `no such command` on a clean mint and every crate release fails at its first
406 gate. That is not a finding this file can make -- the image is correct -- so it
407 is `build/check-host.sh`'s `toolchain` row, which asks an installed machine for
408 the pinned channel and for those two components.
409
410 ## Rehearsing a role in a container
411
412 The four fw13 rehearsals ran the machine's real work inside a container from the
413 built image, on the Pop install, before the disk was touched. Four things about
414 that are not obvious and each one cost a run to find:
415
416 - **Mount the tree at `/var/home/max/Code`, not `/home/max/Code`.** On a bootc
417 image `/home` is a symlink to `var/home` and `/var/home` does not exist, so
418 podman cannot create the mountpoint through it. Mounted at the real path,
419 `/home/max/Code/...` still resolves inside.
420 - **Run `--user 1000:1000`**, or cargo's writes land root-owned in the tree.
421 Note that podman then injects a passwd entry whose home is `/`, so anything
422 inside that reads `$HOME` or passwd sees `/` rather than `/var/home/max`. Set
423 `HOME` explicitly, and mount at `/Code` when running `check-host.sh` this way.
424 - **A `--rm` container takes its artifacts with it.** Put `CARGO_TARGET_DIR`,
425 `CARGO_HOME` and `RUSTUP_HOME` on a mounted volume; that also caches the
426 dependency build and tauri-cli between rehearsals.
427 - **The image builds are rootful** (see `build/build-image.sh`), so the image is
428 in root's container store and `podman run` needs the same privilege to find it.
429
430 ## The answer sheet
431
432 A recipe can also answer the questions `alloy install` would otherwise ask. The
433 identity step writes them to `/usr/lib/alloy/answers.toml`, beside the baked
434 `authorized_keys`, and `crates/alloy/src/preseed.rs` reads that file once when
435 the wizard opens. A step the sheet answers in full is skipped.
436
437 | Argument | Answers | Notes |
438 |---|---|---|
439 | `ALLOY_HOSTNAME` | the machine's name | also rewrites `DEFAULT_HOSTNAME`; the build asserts the two agree |
440 | `ALLOY_USERNAME` | the account to create | the password is never here |
441 | `ALLOY_DISK` | which disk | `single-internal` or `single-internal-nvme`; a device path is refused |
442 | `ALLOY_ENCRYPT` | the encryption checkbox | `yes` leaves the passphrase to be typed; `no` answers the whole step |
443 | `ALLOY_LOCATE_TIMEZONE` | the timezone lookup | both this and the hostname are needed to skip that step |
444
445 Two rules govern what may go in, and neither is a preference.
446
447 **No secrets, ever.** Not the account password, not the LUKS passphrase. The
448 identity step's own comment states the invariant: the image "can be kept, copied
449 or rebuilt without care and a leak of it costs nothing". A passphrase written
450 into the sheet would be in every layer cache, in every `podman save`, and on
451 every stick written from the medium. So the account and encryption steps are
452 still shown, with everything except the secret already filled in. A fully
453 unattended install is not what this buys, and saying otherwise would be a lie
454 about where the secrets are.
455
456 **The disk is a rule, not a path.** `single-internal-nvme` means "the one
457 non-removable NVMe in this machine", and a rule that matches nothing, or matches
458 two, falls back to asking and says why in the status line. A medium that erased
459 `/dev/nvme0n1` on sight would be one wrong laptop away from erasing the wrong
460 machine.
461
462 What that leaves for fw12, whose recipe answers everything it can: the disk and
463 hostname steps are skipped, the account step opens with the username and ssh key
464 already in it and the password to type, and the encryption step opens with the
465 box ticked and the passphrase to type. The review names every answer that came
466 from the medium, because a screen whose job is to be checked has to say which
467 lines nobody typed.
468
469 `build/vmtest/install_preseeded.py` drives exactly that install in a VM, and
470 `crates/alloy/tests/answer_sheet.rs` pins the Containerfile and the installer to
471 the same keys and the same disk vocabulary, since neither can see the other.
472
473 ## Version fields
474
475 Three numbers describe an Alloy machine, and they move on three different clocks. That is why os-release carries three fields rather than one, and why `alloy --version` prints all of them:
476
477 alloy 0.1.0
478 image 0.1 (build 20260816.143012, Fedora 43)
479
480 | Field | What it is | When it moves |
481 |-------|-----------|---------------|
482 | `VERSION_ID` | the product | on a release, edited by hand in `usr/lib/os-release` |
483 | `IMAGE_VERSION` | the build | every build, stamped by the Containerfile |
484 | `ALLOY_BASE` | the Fedora base | when the `FROM` line moves |
485
486 `VERSION` and `PRETTY_NAME` are freeform display and carry all three composed together. The first line of `alloy --version` is the console's own crate version, which is a fourth number and is allowed to disagree with the product: the hotfix channel exists to put a newer console on an older image.
487
488 The build stamp is `<YYYYMMDD>.<serial>`, UTC, and is not in the committed os-release. A placeholder there would be a lie on any machine where the stamping step silently stopped working, so the committed file says `(build <n>, ...)` and the build fails if a literal `<n>` survives. `build/build-image.sh` and `build/build-iso.sh` always pass a stamp (the serial is the UTC time of day, so same-day rebuilds differ without a counter kept anywhere); a bare `podman build` past them passes none, and an unstamped image reports `0.1 (Fedora 43)` rather than inventing a number.
489
490 Not a commit count: that is identical across rebuilds of one commit, which is exactly the pair the field exists to tell apart.
491
492 `/etc/dnf/vars/releasever` states the base version too, and stays independent of `ALLOY_BASE` on purpose. It is a file, and it overrides `$releasever` expansion whatever os-release says; a pin readable out of the thing it overrides is not a pin.
493
494 ## The names a machine shows
495
496 Three different surfaces name Alloy before anything of ours has run, and only two
497 of them are ours to set. Measured 2026-09-07 under OVMF; recorded here because
498 the temptation is to assume the first one is settable and spend a day on it.
499
500 **The installer medium's entry in a firmware boot menu is not ours.** For
501 removable media with no NVRAM entry, the firmware synthesizes the description
502 from the USB device itself. EDK2 named our medium
503 `UEFI QEMU QEMU USB HARDDRIVE 1-0000:00:03.0-1` -- vendor string, product
504 string, PCI path, and nothing from the medium at all. No volume label,
505 application ID or partition name reaches it. A stick that says "Alloy" in a boot
506 menu is not a thing this project can produce, and vendor firmware that shows the
507 ESP's FAT label instead is the only variation worth hoping for, which is why that
508 label is `ALLOY INST` rather than an identifier.
509
510 **An installed machine's entry is ours**, and it comes from a file rather than
511 from a command. shim ships `BOOTX64.CSV` beside itself in the ESP's vendor
512 directory; when the machine has no NVRAM entry pointing at shim -- every fresh
513 install, because bootupd writes the ESP and calls no `efibootmgr` -- the firmware
514 runs `\EFI\BOOT\BOOTX64.EFI`, which is shim, which chain-loads `fbx64.efi`,
515 which reads the CSV and creates the entry. Field 1 is the binary, field 2 is the
516 description a boot menu shows. Stock Fedora ships
517 `shimx64.efi,Fedora,,This is the boot entry for Fedora`, so an installed Alloy
518 machine said Fedora on the first screen after power-on until the Containerfile
519 rewrote it.
520
521 The vendor directory stays `EFI/fedora` and field 1 stays `shimx64.efi`. Both are
522 baked into the signed shim, so changing either is a Secure Boot question rather
523 than a branding one; the description is unsigned data.
524
525 **GRUB's own menu** is the third, set from `usr/lib/os-release` and the menu
526 entries in `build/make-iso.sh`.
527
528 ### What the medium says about itself
529
530 Not boot-menu material, but it is how a stick answers which mint it holds without
531 being booted -- a question that otherwise costs an install:
532
533 ```sh
534 xorriso -indev install.iso -pvd_info # or isoinfo -d
535 ```
536
537 | Field | Value |
538 |---|---|
539 | Volume ID | `ALLOY`, which `root=live:CDLABEL=` and GRUB's `search --label` both resolve |
540 | Volume Set ID | the build stamp, e.g. `20260907.144802` |
541 | Application ID | `Alloy Installer` |
542 | Publisher / Preparer / System ID | Make Creative, LLC / Alloy build/make-iso.sh / LINUX |
543
544 The ESP's FAT label must not equal the volume ID. `search --label` looks at every
545 filesystem it can see, the ESP on the same medium included, so two filesystems
546 sharing a label is a coin flip over which becomes `$root`.
547
548 The GPT partition names are xorriso's own (`Gap0`, `Appended2`, `Gap1`) and stay
549 that way. xorriso has no option for them, no firmware reads them for a boot
550 entry, and rewriting the table afterwards would mean rewriting the one the build
551 verifies byte by byte.
552
553 ## Update cadence
554
555 There is no automated trigger today. The image is rebuilt when someone runs `build/build-image.sh`.
556
557 Two triggers are wanted whenever a builder exists, and they are worth writing down now because the second is the one a manual process silently drops:
558
559 - **On a commit to `main`**, so a Containerfile that no longer builds is caught by the person who broke it.
560 - **Weekly**, to pick up upstream Fedora base updates. Nothing in Alloy changes, and the image still does: this is the trigger a human never remembers, and its absence means the base drifts until the next hand build notices.
561
562 Users receive updates via `bootc upgrade`: no push notifications, no forced restart. Alloy's update UX is the standard bootc UX.
563
564 ## Fedora version tracking
565
566 Pinned explicitly, currently `:43`. Bumps to `:44` and beyond are a deliberate act: Alloy tests the new base, updates the Containerfile, ships. Not automatic. This is the "correctness over deadline" pattern applied to base tracking.
567
568 Note that `build/bib-defs.yaml` remains bootc-image-builder's fedora-42 package set, because bib ships no fedora-43 definition. That is deliberate and documented in the file's own header; it is not drift. The file carries no version in its name: bib looks a distro def up as `<ID>-<VERSION_ID>`, so `build/build-image.sh` reads `VERSION_ID` out of `usr/lib/os-release` and mounts the file under whatever name that makes bib ask for.
569
570 ## Open questions
571
572 - [ ] Verify which Alloy packages are in Fedora main vs. need COPRs. Candidates that may need COPRs: `satty`, `wl-screenrec` (depending on Fedora version). Audit at v0 packaging time.
573 - [ ] `bootc-image-builder` for ISO generation. First-time-user path is `bootc install` from a live environment; the ISO is what makes that a smooth experience. Verify the ISO builder handles Alloy's specific package set.
574 - [x] **Containerfile stage policy: multi-stage only where a non-Fedora Rust binary needs to ship.** Currently that is one stage, `rust-build`, and it builds the Alloy console. Everything else comes from RPM in the runtime stage. Do not add stages preemptively. The console is the case the policy exists for: it is the one binary Alloy authors, so no repo can ever carry it, and a distro whose headline surface is its console cannot ship without one. It is built on `fedora:43` rather than on the dev box so the toolchain and glibc match the runtime stage.
575 - [ ] **An always-on x86_64 builder.** The blocker under "CI" above, and the thing the signing key and the weekly base-update rebuild both wait on. astra is always-on and aarch64; fw13 is x86_64 and sleeps.
576 - [ ] **Language toolchains selected per image rather than for everyone.** A Rust toolchain in the base serves Alloy's developer audience and the build-host role, which needs cargo present (sandod compiles in its own workdir and will not run without it). It costs 610 MiB installed, which is the largest single line in the package block and roughly twelve times the hardware-health group. One number for every install is the wrong shape as soon as a second language is wanted: a Go or Python or C toolchain each carries a comparable bill, and no machine wants all of them. Minting is where this resolves, since a per-machine image is already being produced with a baked key, so the toolchain set is another mint-time input rather than a new mechanism. Open: whether the base keeps Rust as the default when selection exists, or drops to none and makes every toolchain a choice. See [[alloy-image-minting]].
577 - [x] Source hosting: **`makenot.work/git/max/alloy`**, our own platform. sourcehut is a backup remote.
578 - [x] ~~**Publish `:latest`, `:<fedora-version>`, and `:<fedora-version>-YYYYMMDD` tags.**~~ **Struck.** A tagging scheme is a publishing question and nothing is published, so there is nothing for it to name. The local build tags `localhost/alloy:local` and a machine adopts it with `bootc switch --transport containers-storage`; reproducibility comes from the source and the pinned base digests rather than from a dated tag. Kept as a struck line rather than deleted, because the scheme reads as an obvious thing to want and this is the answer to whoever proposes it next.
579