Skip to main content

max / alloy

Make bootc container lint fatal, and clear what it was warning about The lint was the one validation step in a file dense with hard assertions that could not fail the build. It had been reporting three warnings on server and four on client into a log nobody read. Two of them were about the upgrade path rather than tidiness. /var and /etc are per-machine on a bootc system, so a directory a package's %post created during a build, or an account useradd created, reaches a fresh install and never reaches a machine that upgrades into the image. Declare both: usr/lib/tmpfiles.d/50-alloy-var.conf both profiles usr/lib/tmpfiles.d/50-alloy-var-client.conf deleted on server, whose geoclue and greetd lines name users that profile has no packages for usr/lib/sysusers.d/50-alloy-greeter.conf replaces the bare useradd, so the account has one definition and it is the one run at boot The greetd symlink masking xdg-desktop-portal for the greeter is the entry with behaviour behind it: greetd's %post is the only thing that writes it, and that runs on the build host. The rest is the build host's own leavings, swept immediately before the lint rather than declared: dnf's caches and log, the ldconfig aux-cache, the appstream catalogs, authselect's checksum, everything under /run. Measured that dnf recreates its trees and that authselect still validates without its checksum. Both RPM specs also warned on every build. Neither carried a %changelog and Fedora sets %source_date_epoch_from_changelog, so SOURCE_DATE_EPOCH went unset and both packages took BUILDTIME and every file mtime from the clock. Measured: two builds seconds apart differed at byte 225. With a fixed-date changelog entry and use_source_date_epoch_as_buildtime they are byte identical. build/rpm/build.sh drives the same specs, so it is covered too. Verified by full builds of both profiles: 13 checks passed, zero warnings.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-26 20:58 UTC
Signed with PGP, not checked
Commit: 390f17d5b39746f42a6bc4521abb35f146af3223
Parent: 5a2e6fa
7 files changed, +259 insertions, -21 deletions
M Containerfile +134 -20
@@ -2245,25 +2245,6 @@
2245 2245 fi; \
2246 2246 echo "langs: /usr/share/locale grew $((grown / 1048576)) MB over the base"
2247 2247
2248 - # =====================================================================
2249 - # System user for greetd. greetd drops privileges to this account
2250 - # before spawning tuigreet; without it greetd exits with
2251 - # "configured default session user 'greeter' not found". Fedora
2252 - # bootc's minimal user database does not include it.
2253 - # =====================================================================
2254 - #
2255 - # Client-only. `useradd` would succeed on either profile, which is exactly
2256 - # why this needs a branch rather than being left alone: a system account for
2257 - # a greeter that is not installed is the sort of thing nobody notices and
2258 - # nobody can explain later. The `else` asserts it is really absent.
2259 - RUN if [ "$PROFILE" = client ]; then \
2260 - useradd -M -r -s /sbin/nologin greeter; \
2261 - else \
2262 - getent passwd greeter >/dev/null \
2263 - && { echo "profile=server has a greeter account but no greeter" >&2; exit 1; }; \
2264 - echo "greeter: no account on this profile"; \
2265 - fi
2266 -
2267 2248 # =====================================================================
2268 2249 # Register nushell as a legitimate login shell.
2269 2250 # =====================================================================
@@ -2335,6 +2316,62 @@
2335 2316 COPY usr/ /usr/
2336 2317 COPY --from=rust-build /staged-skel/ /
2337 2318
2319 + # =====================================================================
2320 + # What has to reach a machine that UPGRADED into this image, not only
2321 + # one installed from it: the /var declarations and the greeter account.
2322 + # =====================================================================
2323 + # Both halves are here for one reason. /var and /etc are per-machine on a bootc
2324 + # system: `bootc install` copies the image's copies into the stateroot, and an
2325 + # upgrade re-syncs /usr and leaves both alone. So anything that exists only
2326 + # because a package's %post or a `useradd` ran during a build lands on fresh
2327 + # installs and on nothing else. systemd-tmpfiles and systemd-sysusers run at
2328 + # every boot, and a declaration each is what closes the gap. `bootc container
2329 + # lint` is what names the two cases, under `var-tmpfiles` and `sysusers`, and
2330 + # since 2026-08-26 the lint at the bottom of this file is fatal — so a package
2331 + # added later that drops a new /var directory or a new account stops the build
2332 + # here rather than warning into a log nobody reads.
2333 + #
2334 + # The account is created here rather than up beside the greetd install because
2335 + # its definition arrives with `COPY usr/ /usr/` just above, and nothing between
2336 + # the two needs it. It was a bare `useradd` until 2026-08-26; the sysusers file
2337 + # is now the single definition, and this runs it.
2338 + #
2339 + # The client half is a separate file because it cannot merely be inert on
2340 + # `server`: `d /var/lib/geoclue 0755 geoclue geoclue` and the three greetd
2341 + # lines name users that arrive with packages this profile does not install, and
2342 + # systemd-tmpfiles fails a line whose user does not resolve. So it is deleted
2343 + # rather than shipped and ignored, and the `else` proves the deletion happened.
2344 + #
2345 + # --dry-run --create on each file is the check that matters, and it is the same
2346 + # one the flatpak rule gets further down: a tmpfiles line with a typo in it is
2347 + # accepted by every build step except the boot it silently does nothing on.
2348 + RUN set -eux; \
2349 + greeter=/usr/lib/sysusers.d/50-alloy-greeter.conf; \
2350 + common=/usr/lib/tmpfiles.d/50-alloy-var.conf; \
2351 + client=/usr/lib/tmpfiles.d/50-alloy-var-client.conf; \
2352 + [ -f "$common" ] || { echo "$common did not land; every /var directory in this image is undeclared" >&2; exit 1; }; \
2353 + systemd-tmpfiles --dry-run --create "$common" >/dev/null \
2354 + || { echo "systemd-tmpfiles rejects $common" >&2; exit 1; }; \
2355 + if [ "$PROFILE" = client ]; then \
2356 + [ -f "$client" ] || { echo "$client did not land" >&2; exit 1; }; \
2357 + systemd-tmpfiles --dry-run --create "$client" >/dev/null \
2358 + || { echo "systemd-tmpfiles rejects $client" >&2; exit 1; }; \
2359 + grep -q '^L .*/xdg-desktop-portal\.service .*/dev/null$' "$client" \
2360 + || { echo "$client no longer masks the greeter's xdg-desktop-portal; greetd's %post is the only thing writing that symlink and it runs on the build host" >&2; exit 1; }; \
2361 + [ -f "$greeter" ] || { echo "$greeter did not land; the greeter account has no definition" >&2; exit 1; }; \
2362 + systemd-sysusers "$greeter"; \
2363 + getent passwd greeter >/dev/null \
2364 + || { echo "systemd-sysusers accepted $greeter but created no greeter account; greetd would exit with 'configured default session user not found'" >&2; exit 1; }; \
2365 + else \
2366 + rm -f "$client" "$greeter"; \
2367 + [ ! -e "$client" ] && [ ! -e "$greeter" ] \
2368 + || { echo "profile=server still carries the client /var declarations or the greeter account definition, neither of which has packages here" >&2; exit 1; }; \
2369 + getent passwd greeter >/dev/null \
2370 + && { echo "profile=server has a greeter account but no greeter" >&2; exit 1; }; \
2371 + echo "greeter: no account on this profile"; \
2372 + fi; \
2373 + echo "var: tmpfiles declarations present and accepted for profile=$PROFILE"
2374 +
2338 2375 # =====================================================================
2339 2376 # The Firefox configuration is what pays for picking Firefox.
2340 2377 # =====================================================================
@@ -4214,8 +4251,85 @@
4214 4251 echo "fonts: no font stack on this profile, which is what a headless machine draws with"; \
4215 4252 fi
4216 4253
4254 + # =====================================================================
4255 + # The build host's leavings, deleted rather than declared.
4256 + # =====================================================================
4257 + # Everything above declares the /var content this image is supposed to carry.
4258 + # This is the other half: content that is in the image only because dnf,
4259 + # semodule, ldconfig and authselect ran on the machine that built it, and that
4260 + # no running machine needs.
4261 + #
4262 + # It is last on purpose. Anything below it that reached for dnf would put the
4263 + # caches back after the sweep, and the lint that follows is the thing that
4264 + # would say so.
4265 + #
4266 + # What each line is, and why deleting it is safe rather than merely quiet:
4267 + #
4268 + # /run and /tmp are tmpfs at boot, so their content is unreachable on a
4269 + # running machine no matter what. What is in there is dnf's lock directory
4270 + # and the intermediate .cil files semodule writes while compiling policy.
4271 + #
4272 + # /var/log/dnf5.log* is the BUILD HOST's package log: which packages were
4273 + # installed when, from which repo URLs. It is not this machine's history and
4274 + # nothing reads it, but it does ship to every install.
4275 + #
4276 + # /var/lib/dnf and /var/cache/libdnf5 are dnf's own state and cache. The
4277 + # `countme` stamps in there are upstream's install-counting telemetry, and
4278 + # the pubring copies are repo keys dnf re-fetches. MEASURED 2026-08-26: dnf
4279 + # answers a query with both trees deleted and recreates what it needs.
4280 + #
4281 + # /var/cache/ldconfig/aux-cache and the appstream .xb catalogs are caches
4282 + # with a generator behind each of them.
4283 + #
4284 + # /var/lib/authselect/checksum records the state of the PAM files authselect
4285 + # generated, so it can notice a hand edit. MEASURED 2026-08-26: with it gone
4286 + # `authselect check` still answers "Current configuration is valid" and
4287 + # `authselect current` still names the profile and all four features.
4288 + #
4289 + # WHAT THIS DOES NOT DO is make the pulled image smaller. These files were
4290 + # written in layers far above, and deleting them here writes a whiteout rather
4291 + # than reclaiming the bytes. What it changes is the filesystem `bootc install`
4292 + # writes to a disk, which is the one an installed machine actually carries.
4293 + # Reclaiming the registry bytes would mean cleaning inside the layer that made
4294 + # the mess, which is a different and much larger change to this file.
4295 + #
4296 + # The sweep of /run tolerates failures, and the lint below is why that is not a
4297 + # hole. podman bind-mounts the host's /run/systemd/resolve/stub-resolv.conf into
4298 + # the build container to give it DNS, so a plain `rm -rf /run/*` dies on a busy
4299 + # mount and takes the build with it. Those mounts are not image content — they
4300 + # are absent from the lint's own reading of a built image — so the right move is
4301 + # to sweep what can be swept and let `bootc container lint --fatal-warnings`
4302 + # below be the assertion about what is left. It reads the finished filesystem,
4303 + # which is the thing being claimed about, rather than this loop's exit status.
4304 + RUN set -eux; \
4305 + find /run /tmp -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true; \
4306 + rm -f /var/log/dnf5.log*; \
4307 + rm -rf /var/lib/dnf /var/cache/libdnf5; \
4308 + rm -f /var/cache/ldconfig/aux-cache; \
4309 + rm -f /var/cache/swcatalog/cache/*.xb; \
4310 + rm -f /var/lib/authselect/checksum; \
4311 + authselect check >/dev/null \
4312 + || { echo "authselect stopped validating once its checksum was removed; keep the file and declare it instead" >&2; exit 1; }; \
4313 + echo "sweep: build-host caches, logs and /run content removed"
4314 +
4217 4315 # =====================================================================
4218 4316 # bootc validation — fails the build if the image isn't a valid
4219 4317 # bootable container.
4220 4318 # =====================================================================
4221 - RUN bootc container lint
4319 + # --fatal-warnings since 2026-08-26. Without it this was the one validation
4320 + # step in a file otherwise dense with hard assertions that could not fail the
4321 + # build, and it spent an unknown number of builds reporting three warnings on
4322 + # `server` and four on `client` that nothing read. That is the same shape of
4323 + # problem as a sweep grid nobody opens: a detector whose output reaches no one
4324 + # is not a control.
4325 + #
4326 + # --no-truncate because the default prints five entries per lint and then a
4327 + # count, which on a failing build is the half of the list you need hidden
4328 + # behind the half you have. The output is only read when it has already
4329 + # stopped the build, so there is nothing to keep short.
4330 + #
4331 + # --skip is deliberately not used. It exists, and gating with the known
4332 + # warnings suppressed was the cheaper option; it was refused because a skip
4333 + # list is a second place to record which warnings are acceptable, and it goes
4334 + # stale silently the moment the reason for one of them is fixed.
4335 + RUN bootc container lint --fatal-warnings --no-truncate
M docs/IMAGE.md +1 -1
@@ -104,7 +104,7 @@
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 106 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.
107 - 9. **Validation:** `bootc container lint` runs at build.
107 + 9. **Validation:** `bootc container lint --fatal-warnings` runs at build. Fatal since 2026-08-26: it was advisory before that, and spent an unknown number of builds reporting three warnings on `server` and four on `client` that nothing read. 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`.
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
@@ -1,5 +1,25 @@
1 1 %global debug_package %{nil}
2 2
3 + # Timestamps here are a constant, not the clock.
4 + #
5 + # Fedora sets %%source_date_epoch_from_changelog by default and this spec had no
6 + # %%changelog, so SOURCE_DATE_EPOCH went unset and every build stamped BUILDTIME
7 + # and every file mtime with the second rpmbuild happened to run. MEASURED
8 + # 2026-08-26: two builds of the same binary, seconds apart, produced RPMs that
9 + # differ. rpmbuild said so on every build too, in a warning nobody read.
10 + #
11 + # It defeats what build/rpm is for downstream. A rebuild lockfile that pins the
12 + # config exactly (GoingsOn 6e1f433a) would still hand back a different RPM every
13 + # run, and a mirrored layered set (7ee5a694) could not tell a re-cut package from
14 + # a changed one.
15 + #
16 + # So the %%changelog at the bottom carries a date, not a history. The date is
17 + # fixed on purpose and nothing reads the entry as a log: what changed between two
18 + # builds is the version, and the version is in the file name. The clamp that
19 + # applies it to file mtimes is already Fedora's default; using it for BUILDTIME
20 + # as well is not, hence the line below.
21 + %global use_source_date_epoch_as_buildtime 1
22 +
3 23 Name: alloy
4 24 Version: %{alloy_version}
5 25 Release: 1%{?dist}
@@ -53,3 +73,6 @@
53 73 %{_bindir}/alloy
54 74
55 75 %changelog
76 + * Wed Aug 26 2026 Make Creative <info@makenot.work> - 0-0
77 + - The date above is the package epoch, fixed so two builds of one binary
78 + produce one RPM. See the header; this is not a change log.
@@ -1,5 +1,25 @@
1 1 %global debug_package %{nil}
2 2
3 + # Timestamps here are a constant, not the clock.
4 + #
5 + # Fedora sets %%source_date_epoch_from_changelog by default and this spec had no
6 + # %%changelog, so SOURCE_DATE_EPOCH went unset and every build stamped BUILDTIME
7 + # and every file mtime with the second rpmbuild happened to run. MEASURED
8 + # 2026-08-26: two builds of the same binary, seconds apart, produced RPMs that
9 + # differ. rpmbuild said so on every build too, in a warning nobody read.
10 + #
11 + # It defeats what build/rpm is for downstream. A rebuild lockfile that pins the
12 + # config exactly (GoingsOn 6e1f433a) would still hand back a different RPM every
13 + # run, and a mirrored layered set (7ee5a694) could not tell a re-cut package from
14 + # a changed one.
15 + #
16 + # So the %%changelog at the bottom carries a date, not a history. The date is
17 + # fixed on purpose and nothing reads the entry as a log: what changed between two
18 + # builds is the version, and the version is in the file name. The clamp that
19 + # applies it to file mtimes is already Fedora's default; using it for BUILDTIME
20 + # as well is not, hence the line below.
21 + %global use_source_date_epoch_as_buildtime 1
22 +
3 23 Name: shop
4 24 Version: %{shop_version}
5 25 Release: 1%{?dist}
@@ -68,3 +88,6 @@
68 88 %{_bindir}/shop
69 89
70 90 %changelog
91 + * Wed Aug 26 2026 Make Creative <info@makenot.work> - 0-0
92 + - The date above is the package epoch, fixed so two builds of one binary
93 + produce one RPM. See the header; this is not a change log.
@@ -1,0 +1,18 @@
1 + # The account greetd drops to before spawning tuigreet. Without it greetd exits
2 + # with "configured default session user 'greeter' not found", and Fedora bootc's
3 + # minimal user database does not carry one.
4 + #
5 + # A sysusers file rather than the `useradd` this used to be, and the difference
6 + # is the upgrade path rather than tidiness. /etc is per-machine on a bootc
7 + # system: an account baked into the image's /etc/passwd reaches a fresh install
8 + # and never reaches a machine that upgrades into the image carrying it. Fedora
9 + # runs systemd-sysusers on every boot, so a declaration here lands on both.
10 + # `bootc container lint` is what names the difference, under `sysusers`.
11 + #
12 + # Client-only. It is deleted on `server`, which has no greeter to drop to, and
13 + # the Containerfile asserts the account is absent there.
14 + #
15 + # Home is `-`, which resolves to /, because there is no /home/greeter and there
16 + # should not be: greetd keeps the greeter's own state under /var/lib/greetd,
17 + # which the greetd package owns.
18 + u greeter - "greetd greeter" - /sbin/nologin
@@ -1,0 +1,37 @@
1 + # The client profile's half of 50-alloy-var.conf. See that file for why /var
2 + # content needs declaring at all.
3 + #
4 + # Deleted on `server`, and it has to be rather than merely being harmless there:
5 + # the geoclue and greetd lines name users that arrive with their packages, and
6 + # systemd-tmpfiles fails a line whose user does not resolve. The Containerfile
7 + # removes it in the profile block and asserts it is gone.
8 + #
9 + # Modes and owners are the ones the packages themselves created, read back off a
10 + # built image rather than chosen here. A line that widens one of them would be a
11 + # permissions change nobody asked for, wearing a lint fix as a disguise.
12 + #
13 + # The symlink at the bottom is not a directory and not ours: greetd's %post
14 + # writes it to mask xdg-desktop-portal for the greeter's session, so a login
15 + # screen does not start a portal. It is the one entry here with behaviour behind
16 + # it, which is exactly why it cannot be left to a scriptlet that only ever runs
17 + # on the machine that built the image.
18 + #
19 + #Type Path Mode User Group Age Argument
20 + d /var/cache/libX11 0755 root root - -
21 + d /var/cache/libX11/compose 0755 root root - -
22 + d /var/cache/swcatalog 0755 root root - -
23 + d /var/cache/swcatalog/cache 0755 root root - -
24 + d /var/cache/swcatalog/gv 0755 root root - -
25 + d /var/cache/swcatalog/icons 0755 root root - -
26 + d /var/cache/swcatalog/xml 0755 root root - -
27 + d /var/lib/color 0755 root root - -
28 + d /var/lib/color/icc 0755 root root - -
29 + d /var/lib/flatpak 0755 root root - -
30 + d /var/lib/fprint 0700 root root - -
31 + d /var/lib/geoclue 0755 geoclue geoclue - -
32 + d /var/lib/greetd/.config 0755 greetd greetd - -
33 + d /var/lib/greetd/.config/systemd 0755 greetd greetd - -
34 + d /var/lib/greetd/.config/systemd/user 0755 greetd greetd - -
35 + d /var/log/speech-dispatcher 0700 root root - -
36 + d /var/spool/cups 0710 root root - -
37 + L /var/lib/greetd/.config/systemd/user/xdg-desktop-portal.service - - - - /dev/null
@@ -1,0 +1,23 @@
1 + # Directories in /var that packages create at install time and then rely on at
2 + # runtime, declared so they exist on a machine that upgraded into this image
3 + # rather than only on one installed from it.
4 + #
5 + # /var is per-machine on a bootc system. `bootc install` copies the image's /var
6 + # into the stateroot, so a fresh install gets everything; an upgrade re-syncs
7 + # /usr and leaves /var alone, so a directory that arrives in a later image never
8 + # appears on a machine already running. Anything under /var that has to be there
9 + # needs a tmpfiles line, and `bootc container lint` fails the build on one that
10 + # does not have one (`var-tmpfiles`).
11 + #
12 + # This file is the part both profiles need. The client's own set is in
13 + # 50-alloy-var-client.conf, which is deleted on `server` — several of its lines
14 + # name users that only exist alongside the packages that create them, and
15 + # systemd-tmpfiles errors at boot on a line naming a user that is not there.
16 + #
17 + # What is NOT here: dnf's caches, /var/log/dnf5.log, the ldconfig aux-cache and
18 + # authselect's checksum. Those are the build host's leavings rather than
19 + # anything a running machine needs, and the Containerfile deletes them before
20 + # the lint instead of declaring them.
21 + #
22 + #Type Path Mode User Group Age Argument
23 + d /var/cache/tailscale 0755 root root - -