Skip to main content

max / alloy

Give the image a real version: product, build stamp and Fedora base os-release said 0.0 in four places and meant one thing, so two machines on the same product version were indistinguishable. Three fields now, moving on three clocks: VERSION_ID is the product and moves on a release, IMAGE_VERSION is stamped by every build, ALLOY_BASE tracks the FROM line. VERSION and PRETTY_NAME carry all three, and alloy --version prints the composed line under the console's own version. The stamp is <YYYYMMDD>.<serial>, UTC, and is not committed: a placeholder would be a plausible number on any machine where stamping stopped working. The committed file says (build <n>, ...) and the build fails if a literal <n> survives either branch. Both wrapper scripts always pass a stamp; a bare podman build passes none, keeps its layer cache, and produces an image that reports 0.1 (Fedora 43) rather than inventing a number. A commit count was rejected: it is identical across rebuilds of one commit, which is the pair this exists to tell apart. Renaming build/alloy-0.0.yaml to build/bib-defs.yaml is the prep that makes the product version movable. bib resolves a distro def as <ID>-<VERSION_ID>, so build-image.sh derives the mount target from os-release instead; the file itself has no version in it and no longer carries one in its name. /etc/dnf/vars/releasever stays independent of ALLOY_BASE. It is a file and overrides $releasever whatever os-release says, which is the point.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-16 23:22 UTC
Signed with PGP, not checked
Commit: 2ab5471dd78e8e9c1a6b03699603b617e5298a32
Parent: ee6b15f
10 files changed, +285 insertions, -28 deletions
M Containerfile +68 -4
@@ -2141,6 +2141,64 @@
2141 2141 echo "identity: no key baked in"; \
2142 2142 fi
2143 2143
2144 + # =====================================================================
2145 + # The build stamp — which build of the product this image is.
2146 + # =====================================================================
2147 + # Three numbers describe an Alloy machine and they move on three different
2148 + # clocks, which is why they are three fields and not one:
2149 + #
2150 + # VERSION_ID the product. Moves on a release, by hand.
2151 + # IMAGE_VERSION the build. Moves every build, stamped here.
2152 + # ALLOY_BASE the Fedora base. Moves when the FROM line does.
2153 + #
2154 + # VERSION and PRETTY_NAME are freeform display and carry all three, which
2155 + # is what a support conversation reads back.
2156 + #
2157 + # The stamp is not in the committed os-release. A placeholder there would
2158 + # be a lie on any machine where this step silently stopped working, and
2159 + # `usr/lib/os-release` ships `(build <n>, ...)` precisely so that a literal
2160 + # `<n>` reaching an installed machine is a visible failure rather than a
2161 + # plausible number. Both branches below rewrite it and the grep at the end
2162 + # proves it is gone.
2163 + #
2164 + # Empty default is a real state: a bare `podman build` with no wrapper
2165 + # produces an honestly unstamped image, and keeps its layer cache, since an
2166 + # always-changing value here would invalidate every step after it. The
2167 + # wrapper scripts (build/build-image.sh, build/build-iso.sh) always pass
2168 + # one, so anything that can become an artifact is stamped.
2169 + #
2170 + # Format is <date>.<serial>: a commit count was considered and rejected
2171 + # because it is identical across rebuilds of one commit, which is exactly
2172 + # the pair this field exists to tell apart. The wrappers use the UTC time
2173 + # of day as the serial, so same-day rebuilds differ with no state kept
2174 + # anywhere; the validation below accepts any digits, so a builder that
2175 + # wants a plain `.1` can pass one.
2176 + ARG ALLOY_BUILD_STAMP=
2177 +
2178 + RUN set -eu; \
2179 + if [ -n "$ALLOY_BUILD_STAMP" ]; then \
2180 + echo "$ALLOY_BUILD_STAMP" | grep -qE '^[0-9]{8}\.[0-9]+$' \
2181 + || { echo "ALLOY_BUILD_STAMP '$ALLOY_BUILD_STAMP' is not <YYYYMMDD>.<serial>" >&2; exit 1; }; \
2182 + sed -i "s/^VERSION=\"\\(.*\\)build <n>\\(.*\\)\"$/VERSION=\"\\1build $ALLOY_BUILD_STAMP\\2\"/; \
2183 + s/^PRETTY_NAME=\"\\(.*\\)build <n>\\(.*\\)\"$/PRETTY_NAME=\"\\1build $ALLOY_BUILD_STAMP\\2\"/" \
2184 + /usr/lib/os-release; \
2185 + sed -i "/^VERSION_ID=/a IMAGE_VERSION=\"$ALLOY_BUILD_STAMP\"" /usr/lib/os-release; \
2186 + grep -q "^IMAGE_VERSION=\"$ALLOY_BUILD_STAMP\"$" /usr/lib/os-release \
2187 + || { echo "the build stamp did not land in os-release" >&2; exit 1; }; \
2188 + echo "version: build $ALLOY_BUILD_STAMP"; \
2189 + else \
2190 + sed -i 's/^\(VERSION\|PRETTY_NAME\)="\(.*\)build <n>, \(.*\)"$/\1="\2\3"/' \
2191 + /usr/lib/os-release; \
2192 + echo "version: unstamped build"; \
2193 + fi; \
2194 + if grep -q '<n>' /usr/lib/os-release; then \
2195 + echo "the build placeholder is still in os-release" >&2; exit 1; \
2196 + fi; \
2197 + grep -q "^VERSION_ID=" /usr/lib/os-release \
2198 + || { echo "os-release lost its VERSION_ID" >&2; exit 1; }; \
2199 + grep -q "^ALLOY_BASE=" /usr/lib/os-release \
2200 + || { echo "os-release lost its ALLOY_BASE" >&2; exit 1; }
2201 +
2144 2202 # =====================================================================
2145 2203 # The build record — the choices that made this image.
2146 2204 # =====================================================================
@@ -2283,12 +2341,18 @@
2283 2341 # =====================================================================
2284 2342 # Pin dnf's $releasever to the Fedora base version.
2285 2343 # =====================================================================
2286 - # Alloy's os-release carries its own VERSION_ID (0.0), so dnf/librepo
2287 - # would otherwise expand $releasever to "0.0" and request the
2288 - # nonexistent fedora-0.0 repo (404). Every consumer of the Fedora
2289 - # repos — rpm-ostree package layering on the installed system, and
2344 + # Alloy's os-release carries its own product VERSION_ID, so dnf/librepo
2345 + # would otherwise expand $releasever to it and request a nonexistent
2346 + # fedora-<product> repo (404). Every consumer of the Fedora repos —
2347 + # rpm-ostree package layering on the installed system, and
2290 2348 # bootc-image-builder's installer depsolve — needs this pinned to the
2291 2349 # actual base version, independent of Alloy's product version.
2350 + #
2351 + # Deliberately not derived from ALLOY_BASE, which states the same number
2352 + # in os-release. This is a file and overrides $releasever expansion
2353 + # whatever os-release says, which is the property wanted: a pin that can
2354 + # be read out of the thing it exists to override is not a pin. The two
2355 + # move together by hand when the FROM line moves.
2292 2356 RUN echo 43 > /etc/dnf/vars/releasever
2293 2357
2294 2358 # =====================================================================
@@ -3,10 +3,13 @@
3 3 # build-image.sh — build the Alloy bootc image and a disk image
4 4 # (raw by default) with bootc-image-builder.
5 5 #
6 - # Alloy re-brands os-release (ID=alloy, VERSION_ID=0.0). Two consequences
7 - # are handled in the Containerfile (pinned $releasever, disabled build-only
8 - # repos) and one here: bootc-image-builder has no distro definition named
9 - # "alloy-0.0", so build/alloy-0.0.yaml is bind-mounted into its defs dir.
6 + # Alloy re-brands os-release (ID=alloy, with its own VERSION_ID). Two
7 + # consequences are handled in the Containerfile (pinned $releasever, disabled
8 + # build-only repos) and one here: bootc-image-builder has no distro definition
9 + # named "alloy-<VERSION_ID>", so build/bib-defs.yaml is bind-mounted into its
10 + # defs dir under that name. The name is derived from usr/lib/os-release rather
11 + # than hardcoded, so a product version bump does not rename a file whose
12 + # contents have no version in them.
10 13 #
11 14 # Everything runs rootful on purpose: the image build and bib share one
12 15 # container store, so bib finds the image at /var/lib/containers/storage
@@ -40,11 +43,26 @@
40 43 # shellcheck source=build/privilege.sh
41 44 . "$REPO_ROOT/build/privilege.sh"
42 45
46 + # stamp_build_args. The build number the Containerfile bakes into os-release;
47 + # see the header of build/build-stamp.sh.
48 + # shellcheck source=build/build-stamp.sh
49 + . "$REPO_ROOT/build/build-stamp.sh"
50 +
43 51 IMAGE="localhost/alloy:local"
44 52 BIB_IMAGE="quay.io/centos-bootc/bootc-image-builder:latest"
45 - DEF="$REPO_ROOT/build/alloy-0.0.yaml"
53 + DEF="$REPO_ROOT/build/bib-defs.yaml"
46 54 OUTPUT="$REPO_ROOT/output"
47 55
56 + # The name bib will look the def up under. It resolves a distro def as
57 + # <ID>-<VERSION_ID>, both read from the image's os-release, so this has to
58 + # track usr/lib/os-release and not a constant here. Read with `sed` rather
59 + # than sourced: os-release is shell-shaped but this file is not a place to
60 + # execute it.
61 + DEF_VERSION="$(sed -n 's/^VERSION_ID="\{0,1\}\([^"]*\)"\{0,1\}$/\1/p' \
62 + "$REPO_ROOT/usr/lib/os-release")"
63 + [ -n "$DEF_VERSION" ] || { echo "error: no VERSION_ID in usr/lib/os-release" >&2; exit 1; }
64 + DEF_NAME="alloy-${DEF_VERSION}.yaml"
65 +
48 66 TYPE="raw"
49 67 WRITE_DEV=""
50 68 SKIP_BUILD=0
@@ -110,6 +128,7 @@
110 128 echo "==> Building $IMAGE (rootful)"
111 129 # --jobs 2 to overlap the two stages; see the same call in build/build-iso.sh
112 130 # for why two and not more.
131 + stamp_build_args BUILD_ARGS
113 132 priv podman build --jobs 2 "${BUILD_ARGS[@]}" -t "$IMAGE" "$REPO_ROOT"
114 133 else
115 134 echo "==> Skipping image build; reusing $IMAGE"
@@ -123,8 +142,9 @@
123 142 priv podman pull "$BIB_IMAGE"
124 143 }
125 144
126 - # 3. Build the artifact. The alloy-0.0 def is mounted read-only into bib's
127 - # defs directory; librepo (the default) resolves repos from the image.
145 + # 3. Build the artifact. The def is mounted read-only into bib's defs
146 + # directory under the name it looks up ($DEF_NAME); librepo (the default)
147 + # resolves repos from the image.
128 148 echo "==> Building --type $TYPE into $OUTPUT"
129 149
130 150 # bib wants a clean output directory, but clearing it up front means a
@@ -143,7 +163,7 @@
143 163 --security-opt label=type:unconfined_t \
144 164 -v /var/lib/containers/storage:/var/lib/containers/storage \
145 165 -v "$OUTPUT":/output \
146 - -v "$DEF":/usr/share/bootc-image-builder/defs/alloy-0.0.yaml:ro \
166 + -v "$DEF":"/usr/share/bootc-image-builder/defs/$DEF_NAME":ro \
147 167 "$BIB_IMAGE" \
148 168 --type "$TYPE" \
149 169 --log-level info \
@@ -42,6 +42,11 @@
42 42 # shellcheck source=build/privilege.sh
43 43 . "$REPO_ROOT/build/privilege.sh"
44 44
45 + # stamp_build_args. The build number the Containerfile bakes into os-release;
46 + # see the header of build/build-stamp.sh.
47 + # shellcheck source=build/build-stamp.sh
48 + . "$REPO_ROOT/build/build-stamp.sh"
49 +
45 50 IMAGE="localhost/alloy:local"
46 51 BUILDER="localhost/alloy-iso-builder:local"
47 52 OUTPUT="$REPO_ROOT/output"
@@ -144,6 +149,7 @@
144 149 # number above that buys nothing while making the interleaved log harder to
145 150 # read. The rust stage caps its own rustc jobs from RAM, which is what keeps
146 151 # the overlap from turning into paging — see the Containerfile.
152 + stamp_build_args BUILD_ARGS
147 153 priv podman build --jobs 2 "${BUILD_ARGS[@]}" -t "$IMAGE" "$REPO_ROOT"
148 154 else
149 155 privc podman image exists "$IMAGE" || die "$IMAGE not in the root store; drop --skip-build"
M docs/IMAGE.md +23 -2
@@ -103,13 +103,34 @@
103 103 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.
104 104 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.
105 105 7. **Systemd presets:** which services are enabled by default (syncthing off by default, gammastep off until enrolled, alloy-hinged conditionally on FW12, etc.).
106 - 8. **Branding:** os-release, plymouth splash.
106 + 8. **Branding:** os-release, plymouth splash. The build stamps the image's build number into os-release here; see [Version fields](#version-fields).
107 107 9. **Validation:** `bootc container lint` runs at build.
108 108
109 109 **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.
110 110
111 111 The browser ships as an RPM baked into the image: one code path, no first-boot delay, and no first-run network dependency. Alloy ships no configuration for it at all, which is why it is the browser Alloy ships (see [STACK.md](STACK.md#browser)). The `flatpak` client is included so users can pull Flathub-only apps on demand post-install; no Flatpaks are provisioned at build or first-boot time.
112 112
113 + ## Version fields
114 +
115 + 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:
116 +
117 + alloy 0.1.0
118 + image 0.1 (build 20260816.143012, Fedora 43)
119 +
120 + | Field | What it is | When it moves |
121 + |-------|-----------|---------------|
122 + | `VERSION_ID` | the product | on a release, edited by hand in `usr/lib/os-release` |
123 + | `IMAGE_VERSION` | the build | every build, stamped by the Containerfile |
124 + | `ALLOY_BASE` | the Fedora base | when the `FROM` line moves |
125 +
126 + `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.
127 +
128 + 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.
129 +
130 + A commit count was considered as the stamp and rejected: it is identical across rebuilds of one commit, which is exactly the pair the field exists to tell apart.
131 +
132 + `/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.
133 +
113 134 ## Update cadence
114 135
115 136 There is no automated trigger today. The image is rebuilt when someone runs `build/build-image.sh`.
@@ -125,7 +146,7 @@
125 146
126 147 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.
127 148
128 - Note that `build/alloy-0.0.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.
149 + 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.
129 150
130 151 ## Open questions
131 152
@@ -1,7 +1,8 @@
1 1 NAME="Alloy"
2 - PRETTY_NAME="Alloy 0.0 (pre-v0)"
3 - VERSION="0.0"
4 - VERSION_ID="0.0"
2 + PRETTY_NAME="Alloy 0.1 (build <n>, Fedora 43)"
3 + VERSION="0.1 (build <n>, Fedora 43)"
4 + VERSION_ID="0.1"
5 + ALLOY_BASE="43"
5 6 ID=alloy
6 7 ID_LIKE=fedora
7 8 VARIANT="Base"
@@ -110,7 +110,14 @@
110 110 /// writes a boot menu entry. Reading the source rather than the link.
111 111 const OS_RELEASE: &str = "/usr/lib/os-release";
112 112
113 - /// The image's version, as `VERSION_ID` from os-release.
113 + /// The image's version: the product, the build it came from, and the Fedora
114 + /// base it was built on, composed into one line.
115 + ///
116 + /// Three fields because they move on three clocks. `VERSION_ID` is the product
117 + /// and moves on a release; `IMAGE_VERSION` is stamped by every build;
118 + /// `ALLOY_BASE` follows the Containerfile's `FROM`. Two machines on the same
119 + /// product version can be different images, and that is exactly the pair a
120 + /// support conversation has to tell apart.
114 121 ///
115 122 /// Separate from the console's `CARGO_PKG_VERSION`, and the two diverge on
116 123 /// purpose: the hotfix channel exists to put a newer console on an older
@@ -129,6 +136,14 @@
129 136
130 137 /// The parse, split from the read so it can be tested against the os-release
131 138 /// this repo actually ships rather than against the host's.
139 + ///
140 + /// Composed here and not in `notice_text`, which stays a formatter with no
141 + /// os-release knowledge.
142 + ///
143 + /// Each of the two trailing fields is dropped rather than filled in when it is
144 + /// missing. An unstamped image is a real state — a bare `podman build` past the
145 + /// wrapper scripts — and `0.1 (Fedora 43)` says less than the full line while
146 + /// saying nothing false, which is what `unknown` would do.
132 147 fn version_from(text: &str) -> Option<String> {
133 148 // `strip_prefix` on the key and then on `=`, in that order, so a key that
134 149 // is a prefix of another does not match it: `ID` against `ID_LIKE=fedora`
@@ -141,7 +156,18 @@
141 156 if field("ID").as_deref() != Some("alloy") {
142 157 return None;
143 158 }
144 - field("VERSION_ID")
159 + let product = field("VERSION_ID")?;
160 + let detail: Vec<String> = [
161 + field("IMAGE_VERSION").map(|build| format!("build {build}")),
162 + field("ALLOY_BASE").map(|base| format!("Fedora {base}")),
163 + ]
164 + .into_iter()
165 + .flatten()
166 + .collect();
167 + if detail.is_empty() {
168 + return Some(product);
169 + }
170 + Some(format!("{product} ({})", detail.join(", ")))
145 171 }
146 172
147 173 /// Which machine is being built.
@@ -1764,11 +1790,47 @@
1764 1790 /// Against the file the image actually ships, not a fixture, so an
1765 1791 /// os-release edit that drops or renames `VERSION_ID` fails here rather
1766 1792 /// than by silently removing the image line from `alloy --version`.
1793 + ///
1794 + /// The committed file carries no `IMAGE_VERSION`: the build stamps it, and
1795 + /// a placeholder here would be a lie on any machine where the stamping
1796 + /// step stopped working. So this is the unstamped shape on purpose, and
1797 + /// the product version is asserted rather than the whole line.
1767 1798 #[test]
1768 1799 fn the_shipped_os_release_states_an_image_version() {
1769 1800 let shipped = concat!(env!("CARGO_MANIFEST_DIR"), "/../../usr/lib/os-release");
1770 1801 let text = std::fs::read_to_string(shipped).expect("the repo ships usr/lib/os-release");
1771 - assert_eq!(version_from(&text).as_deref(), Some("0.0"));
1802 + let version = version_from(&text).expect("the shipped os-release names an image version");
1803 + assert!(version.starts_with("0."), "{version}");
1804 + assert!(
1805 + !version.contains("build"),
1806 + "the stamp is not committed: {version}"
1807 + );
1808 + assert!(version.contains("Fedora"), "{version}");
1809 + }
1810 +
1811 + /// The line a support conversation reads back: product, build and base,
1812 + /// which move on three different clocks and are three fields for that
1813 + /// reason.
1814 + #[test]
1815 + fn a_stamped_image_composes_the_whole_triple() {
1816 + let stamped = "NAME=\"Alloy\"\nVERSION_ID=\"0.1\"\nIMAGE_VERSION=\"20260816.143012\"\n\
1817 + ALLOY_BASE=\"43\"\nID=alloy\nID_LIKE=fedora\n";
1818 + assert_eq!(
1819 + version_from(stamped).as_deref(),
1820 + Some("0.1 (build 20260816.143012, Fedora 43)")
1821 + );
1822 + }
1823 +
1824 + /// An unstamped build is a real state rather than a broken one: a bare
1825 + /// `podman build` past the wrapper scripts produces one. It reports less
1826 + /// and nothing false, which is what a filled-in "unknown" would not do.
1827 + #[test]
1828 + fn an_unstamped_image_says_less_rather_than_something_false() {
1829 + let unstamped = "VERSION_ID=\"0.1\"\nALLOY_BASE=\"43\"\nID=alloy\n";
1830 + assert_eq!(version_from(unstamped).as_deref(), Some("0.1 (Fedora 43)"));
1831 +
1832 + let bare = "VERSION_ID=\"0.1\"\nID=alloy\n";
1833 + assert_eq!(version_from(bare).as_deref(), Some("0.1"));
1772 1834 }
1773 1835
1774 1836 /// The reason `ID` is checked. Every Linux host has an os-release, so a
@@ -498,6 +498,12 @@
498 498 mod tests {
499 499 use super::*;
500 500
501 + /// What `image::version` composes on a stamped machine: product, build and
502 + /// Fedora base. A literal here rather than a read, because these tests are
503 + /// about the shape of the notice and not about parsing os-release, which
504 + /// `image`'s own tests cover.
505 + const IMAGE: &str = "0.1 (build 20260816.143012, Fedora 43)";
506 +
501 507 /// The invariant two build steps depend on and neither can state here:
502 508 /// `Containerfile` asserts `alloy --version | grep -q '^alloy '` to prove
503 509 /// it did not ship the stub main, and `build/rpm/build.sh` reads the first
@@ -505,7 +511,7 @@
505 511 /// break silently if anything is inserted above the version.
506 512 #[test]
507 513 fn the_version_stays_alone_on_the_first_line() {
508 - let notice = notice_text("0.1.0", Some("0.0"));
514 + let notice = notice_text("0.1.0", Some(IMAGE));
509 515 assert_eq!(notice.lines().next(), Some("0.1.0"));
510 516 }
511 517
@@ -513,10 +519,13 @@
513 519 /// machine from a rebuilt one, which is the whole reason both are printed.
514 520 #[test]
515 521 fn a_machine_with_an_image_reports_both_versions() {
516 - let notice = notice_text("0.1.0", Some("0.0"));
522 + let notice = notice_text("0.1.0", Some(IMAGE));
517 523 let mut lines = notice.lines();
518 524 assert_eq!(lines.next(), Some("0.1.0"));
519 - assert_eq!(lines.next(), Some("image 0.0"));
525 + assert_eq!(
526 + lines.next(),
527 + Some("image 0.1 (build 20260816.143012, Fedora 43)")
528 + );
520 529 }
521 530
522 531 /// A dev host has no image, so the line is omitted rather than filled in
@@ -531,7 +540,7 @@
531 540 /// The notice MIT asks to travel with the binary, in both shapes.
532 541 #[test]
533 542 fn the_copyright_notice_survives_either_shape() {
534 - for image in [Some("0.0"), None] {
543 + for image in [Some(IMAGE), None] {
535 544 let notice = notice_text("0.1.0", image);
536 545 assert!(notice.contains("Make Creative, LLC"), "{notice}");
537 546 assert!(notice.contains("License MIT"), "{notice}");
@@ -185,3 +185,37 @@
185 185 "os-release has no DEFAULT_HOSTNAME line for the identity step to rewrite",
186 186 );
187 187 }
188 +
189 + // The build stamp is rewritten into os-release by the same kind of mid-build
190 + // sed as the hostname, and fails the same silent way: if the placeholder is not
191 + // in the committed file the sed matches nothing, the step exits 0, and the
192 + // image ships without a build number. The Containerfile's own grep catches the
193 + // opposite failure (a placeholder that survives), so this covers the half it
194 + // cannot see.
195 + //
196 + // `IMAGE_VERSION` deliberately is NOT in the committed file. A placeholder
197 + // there would be a plausible-looking number on any machine where the stamping
198 + // stopped working, which is worse than an absent field: `alloy --version` drops
199 + // the build clause and says so by saying less.
200 + #[test]
201 + fn the_committed_os_release_carries_what_the_build_stamp_rewrites() {
202 + let os_release = read("usr/lib/os-release");
203 + for field in ["VERSION", "PRETTY_NAME"] {
204 + assert!(
205 + os_release
206 + .lines()
207 + .any(|line| line.starts_with(&format!("{field}=")) && line.contains("build <n>")),
208 + "{field} has no build placeholder for the stamping step to rewrite",
209 + );
210 + }
211 + assert!(
212 + !os_release.contains("IMAGE_VERSION"),
213 + "the build stamp is committed; it must be written by the build or absent",
214 + );
215 + assert!(
216 + os_release
217 + .lines()
218 + .any(|line| line.starts_with("ALLOY_BASE=")),
219 + "os-release has no ALLOY_BASE, so the image cannot say which Fedora it is",
220 + );
221 + }
@@ -1,9 +1,14 @@
1 - # alloy-0.0.yaml — bootc-image-builder distro definition for Alloy.
1 + # bib-defs.yaml — bootc-image-builder distro definition for Alloy.
2 2 #
3 - # Alloy re-brands os-release (ID=alloy, VERSION_ID=0.0), so bootc-image-builder
4 - # looks up a distro def named "alloy-0.0" and ships none. This file is bib's own
3 + # Alloy re-brands os-release (ID=alloy), so bootc-image-builder looks up a
4 + # distro def named "alloy-<VERSION_ID>" and ships none. This file is bib's own
5 5 # fedora-42.yaml anaconda-iso package set verbatim; build/build-image.sh
6 - # bind-mounts it into the builder's defs directory.
6 + # bind-mounts it into the builder's defs directory under whatever name the
7 + # current VERSION_ID makes bib ask for.
8 + #
9 + # The name here carries no version on purpose. There is no version field in the
10 + # file, so a product version bump has no business renaming it; the mount target
11 + # is derived in build-image.sh instead.
7 12 #
8 13 # NOTE (2026-07-19): the base moved to fedora-bootc:43 but this is still the
9 14 # fedora-42 set, because bib ships no fedora-43.yaml (it has only fedora-40 and
@@ -1,0 +1,35 @@
1 + #!/usr/bin/env bash
2 + #
3 + # build-stamp.sh — the build stamp both image builders pass to the Containerfile.
4 + #
5 + # Sourced by build/build-image.sh and build/build-iso.sh, the same way
6 + # build/privilege.sh is: they are the two scripts that turn the image into
7 + # something installable, and an unstamped artifact is the one shape a support
8 + # conversation cannot place.
9 + #
10 + # The Containerfile's ALLOY_BUILD_STAMP defaults empty and stamps nothing, so a
11 + # bare `podman build` keeps its layer cache and honestly says nothing about a
12 + # build number. These scripts always pass one. See the stamping block in the
13 + # Containerfile for the three-clock scheme (product / build / Fedora base).
14 + #
15 + # Format is <YYYYMMDD>.<serial>, UTC. The serial is the time of day, which
16 + # distinguishes same-day rebuilds without keeping a counter anywhere. UTC and
17 + # not local time so two machines in two timezones cannot mint the same stamp
18 + # from different days.
19 +
20 + # Append the stamp to a BUILD_ARGS array, unless the caller already passed one
21 + # by hand. Hand-passing wins: `--build-arg ALLOY_BUILD_STAMP=20260816.1` is how
22 + # a release build pins a stamp it decided elsewhere, and a second value would
23 + # leave which one podman honoured up to argument order.
24 + #
25 + # Usage: stamp_build_args BUILD_ARGS (the array's NAME, not its contents)
26 + stamp_build_args() {
27 + local -n args="$1"
28 + local existing
29 + for existing in "${args[@]}"; do
30 + case "$existing" in
31 + ALLOY_BUILD_STAMP=*) return 0 ;;
32 + esac
33 + done
34 + args+=(--build-arg "ALLOY_BUILD_STAMP=$(date -u +%Y%m%d.%H%M%S)")
35 + }