| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|