max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
10 files changed,
+285 insertions,
-28 deletions
| @@ -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" |
| @@ -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 | + | } |