Skip to main content

max / alloy

Package the console as an RPM, and stop the tree arguing against its own design GO d866e125 subtasks 3 and 6, and the alloy-facing half of 2. Max chose the RPM route 2026-08-14, after the drain measurement showed the wedge is escapable. `build/rpm/` builds `alloy-0.1.0-1.fc43.x86_64.rpm`: one file, glibc, installs clean. The spec packages the binary rather than compiling it, so the Containerfile's rust-build stage stays the single definition of how the console is built and the binary that ships is the one that was tested. `--binary` takes one built elsewhere, which is how a hotfix gets to be the same artifact as the image's. Version comes from the crate and nowhere else, and the script refuses to package a binary whose `--version` disagrees with it. That mistake is invisible afterwards: the RPM installs, rpm reports one version and the binary reports another, and the version is what a person quotes in a bug report. No `--locked` here, unlike the Containerfile, and the reason is the build location rather than a change of heart. This runs inside ~/Code where the `[patch]` block redirects the cross-repo deps, and under it every resolve rewrites the lock, so the flag fails everywhere instead of catching anything. The Containerfile builds in a container that has no such block. Containerfile:72 argued that owning an RPM spec "buys nothing, and Alloy publishes no other artifact". True only while nothing was published, so the conclusion moves with the premise rather than being left for the next reader to trip over. It now also carries the constraint that forces the design: a component the base image carries cannot be replaced client-side, and a COPY into /usr/bin is the worst shape of all, since an unowned file cannot be layered over at all. docs/STACK.md gains a Hotfixes section rather than the "#distribution" one the task named, which does not exist. It states the scope, what a hotfix is allowed to be, why a hotfixable component cannot be in the base, the disposable-layer rule and why dropping layers has to be precise, and that config fixes travel by `alloy theme apply` instead. It also says why none of this weakens the no-phone-home position directly above it: the repo is configured, and configured is not consulted. Signing is still open and the generated .repo says `gpgcheck=0`, which is a marker for unfinished rather than a decision.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-14 22:53 UTC
Signed with PGP, not checked
Commit: ad2958a66f4d5386bccead977e575b9a8598a946
Parent: d196721
5 files changed, +217 insertions, -4 deletions
M .gitignore +5
@@ -58,5 +58,10 @@
58 58 # layertest scratch: RPMs, the generated repo, the target disk, firmware vars.
59 59 /build/layertest/state/
60 60
61 + # The packaged console and its repo metadata. Regenerated by build/rpm/build.sh
62 + # from the crate, so committing it would store what rebuilds from source, and
63 + # it is a 2 MB binary besides.
64 + /build/rpm/out/
65 +
61 66 # Byte-compiled python from the vmtest and layertest helpers.
62 67 __pycache__/
M Containerfile +15 -4
@@ -69,10 +69,21 @@
69 69 # shop, Alloy's terminal.
70 70 # =====================================================================
71 71 # Built from source rather than installed from a repo, because there is no
72 - # repo: shop is first-party and packaged nowhere. Owning an RPM spec, a COPR
73 - # and a release cadence for a binary whose only consumer is this image buys
74 - # nothing, and Alloy publishes no other artifact (distribution is builders,
75 - # not images).
72 + # repo yet for shop. The console has one as of 2026-08-14 (`build/rpm/`), and
73 + # shop is next; this comment used to argue that owning an RPM spec and a
74 + # release cadence "buys nothing, and Alloy publishes no other artifact", which
75 + # was true only while nothing was published. A hotfix channel changes that
76 + # premise, so the conclusion moves with it (GoingsOn task d866e125).
77 + #
78 + # Builders-not-images is untouched by that: users still build their own ISO.
79 + # What the repo buys is that they stop having to rebuild it to receive a fix.
80 + #
81 + # The consequence for this stage, once shop is packaged: a component the base
82 + # image carries cannot be replaced client-side, so a hotfixable component must
83 + # NOT be built into the image at all. It is layered at install time instead.
84 + # See build/layertest/README.md for the measurements that force this, and note
85 + # that a `COPY` into /usr/bin is the worst of the shapes — an unowned file
86 + # cannot even be layered over, it dies in checkout.
76 87 #
77 88 # Pinned by revision, not by branch. An unpinned build is a different image
78 89 # every day for the same Containerfile, and the terminal is the one component
@@ -28,6 +28,26 @@
28 28
29 29 Rejected: **a staleness line that phones out to say something useful**, which is the same request wearing a local-looking label. The line reports the age of your last check and nothing about the world. **Enabling the metadata check by default**, which is the outbound request the principle guards against. **Auto-apply via `bootc-fetch-apply-updates.timer`**, which additionally stages updates and carries reboot semantics onto a machine whose owner did not consent to either. **Asking once at first boot**, the runner-up: it resolves the tension rather than picking a side, and `alloy setup` already exists as the place opt-in questions get asked, but it buys explicit consent by adding a screen to a first-boot flow that is meant to be short, and the answer it would collect is one `alloy update` already collects every time it is run.
30 30
31 + ## Hotfixes: **a repo carrying only our own components**
32 +
33 + **Alloy is distributed as a builder, so a machine that is already installed has no way to receive a fix.** Rebuilding the ISO and writing a drive is the whole recovery path, which is not a thing to ask of somebody whose console has a bug. The answer is a signed RPM repo carrying only Make Creative's own components, layered with rpm-ostree. Builders-not-images is untouched: users still build their own ISO, they stop having to rebuild it to receive a fix.
34 +
35 + **Scope is our own software and nothing else.** The console and shop, all MIT, all ours. Fedora's packages have their own update path and are not our business to re-ship, and shipping anyone else's work here would re-attach the source-availability obligations that distributing a builder rather than an image deliberately avoids.
36 +
37 + **What a hotfix is allowed to be.** A hotfix exists because the shipped software does not work as that release intended. Anything that changes how the software is intended to work is a release, however small the diff, and rides the ordinary path rather than this one. The second half is the channel test: a hotfix is shipped out of band, so a fix that can wait is an ordinary patch and never touches the repo. Security fixes are fixes, on the standing intent that no release be exploitable. Every hotfix is a patch bump; every intent change is at least a minor.
38 +
39 + That definition is narrow on purpose, and here it is load-bearing rather than tidy. Every machine holding a layer is a machine carrying state outside its image, and the fewer of those there are at any moment the better.
40 +
41 + **A hotfixable component cannot be in the base image.** This is measured, not chosen. A component the base carries cannot be replaced client-side: `rpm-ostree install` fails to depsolve against it, and `rpm-ostree override replace` records a request that never activates and survives a reboot as a silent no-op, so the machine reports a hotfix it is not running. A component that is a loose file owned by no package is worse still, and cannot be layered over at all. So the console and shop are layered at install time rather than built into the image. Measurements and how to re-run them: `build/layertest/README.md`.
42 +
43 + **Our layers are disposable, and `alloy update` is what makes them so.** A layered package whose version the base later overtakes wedges the machine: every subsequent update fails to depsolve, permanently, and that is the normal life of a hotfix rather than an edge case. The rule that avoids it is to carry no layer of ours across an upgrade. Drop them, upgrade, re-apply only if the base still lacks the fix. It costs one reboot rather than two, because the two transactions compose into one deployment, and it hands `bootc upgrade` back a deployment it will consent to work on.
44 +
45 + Dropping them has to be precise. A request is recorded under the string that was typed to install it, so a package layered by full name-version-release cannot be removed by its bare name, and rpm-ostree reports that as nothing-to-do rather than as a failure. `alloy update` reads `requested-packages` and removes exactly what it finds. Not `rpm-ostree reset`, which would also drop packages the user layered themselves, and those are not ours to remove.
46 +
47 + **This does not weaken the no-phone-home position above.** The repo is configured on an installed machine, and configured is not consulted: nothing fetches its metadata until somebody runs `alloy update`. A hotfix channel changes what a check can deliver, not whether one happens unasked.
48 +
49 + **Config fixes travel separately, and the RPM channel does not carry them.** `/etc/skel` seeds new users only, so a shipped config change reaches nobody who is already running. `alloy theme apply` is the mechanism that does reach a live home directory, and it records what it wrote so it can tell its own render from something the user edited even after the image has moved. Design half of the same problem, different delivery: see the module docs in `crates/alloy/src/theme_apply.rs`.
50 +
31 51 ## Compositor
32 52
33 53 **Sway.** Mature, i3-style manual tiling (workspaces plus split/tabbed/stacked containers), Wayland, well-packaged on Fedora. The i3 model is the tiling model Alloy wants: predictable, workspace-based, no infinite scroll. C rather than Rust, accepted as a knowing trade: no mature Rust i3-style tiler exists, and the model outweighs toolkit purity here. The whole sway* ecosystem (swaylock, swayidle, swayosd, swaybar) fits behind it with zero glue.
@@ -1,0 +1,55 @@
1 + %global debug_package %{nil}
2 +
3 + Name: alloy
4 + Version: %{alloy_version}
5 + Release: 1%{?dist}
6 + Summary: The Alloy console
7 + License: MIT
8 + URL: https://makenot.work/git/max/alloy
9 +
10 + # The binary is built before rpmbuild runs and handed in through the buildroot,
11 + # rather than compiled from a %%prep/%%build pair here. Two reasons, and both
12 + # are about not owning a second build.
13 + #
14 + # The Containerfile's rust-build stage already produces this binary, with the
15 + # toolchain pin, the `--locked` graph and the cache split that stage exists for.
16 + # A spec that compiled it again would be a second definition of how the console
17 + # is built, and the two would drift in the direction of whichever one somebody
18 + # ran last.
19 + #
20 + # It also keeps the RPM a packaging step rather than a build step, so the same
21 + # binary that was tested is the one that ships. `build/rpm/build.sh` is what
22 + # stages it.
23 + Source0: alloy
24 +
25 + # rpm would otherwise work these out by scanning the binary and require the
26 + # exact soname set of whatever host built it. The console links only glibc and
27 + # its own statically-linked Rust dependencies, and the base image it lands on
28 + # is the one that built it, so the scan buys nothing and costs a package that
29 + # refuses to install across a Fedora minor bump.
30 + AutoReqProv: no
31 + Requires: glibc
32 +
33 + %description
34 + The Alloy console: the `alloy` binary, which is the system's control surface
35 + for network, audio, display, packages, disks, settings and the installer.
36 +
37 + Packaged so a fix can reach an installed machine without rebuilding an ISO and
38 + writing a drive. Alloy is distributed as a builder rather than as an image, so
39 + rpm-ostree layering from a repo carrying only Make Creative's own components is
40 + the delivery path for everything else (GoingsOn task d866e125).
41 +
42 + This package must never be part of a base image. A component the base carries
43 + cannot be replaced client-side — `rpm-ostree install` fails to depsolve against
44 + it and `override replace` records a request that never activates — so a base
45 + that ships the console is a machine that can never be sent a console fix. It is
46 + layered at install time and it stays layered. Measured in build/layertest.
47 +
48 + %install
49 + mkdir -p %{buildroot}%{_bindir}
50 + install -m 0755 %{SOURCE0} %{buildroot}%{_bindir}/alloy
51 +
52 + %files
53 + %{_bindir}/alloy
54 +
55 + %changelog
@@ -1,0 +1,122 @@
1 + #!/usr/bin/env bash
2 + #
3 + # build.sh — package the console as an RPM, and put it in a repo.
4 + #
5 + # build.sh build the console from the tree, package it
6 + # build.sh --binary PATH package a binary somebody else already built
7 + # build.sh --out DIR write somewhere other than build/rpm/out
8 + #
9 + # This is the supply side of the hotfix channel (GoingsOn task d866e125). Alloy
10 + # is distributed as a builder rather than as an image, so a machine that is
11 + # already installed has no way to receive a fix short of rebuilding an ISO and
12 + # writing a drive. A signed repo carrying only Make Creative's own components,
13 + # layered with rpm-ostree, is the answer; this produces what it serves.
14 + #
15 + # WHAT MUST NOT HAPPEN TO THIS PACKAGE. It must never end up in a base image.
16 + # A component the base carries cannot be replaced client-side: `rpm-ostree
17 + # install` fails to depsolve against it, and `override replace` records a
18 + # request that never activates and silently survives a reboot as a no-op. So a
19 + # base that ships the console is a machine that can never be sent a console
20 + # fix. Measured, in build/layertest — read its README before changing where
21 + # this package lands.
22 + #
23 + # Unsigned so far. Signing is its own subtask and its own key story, and a repo
24 + # that can reach every installed machine has no business being unsigned for
25 + # long. `gpgcheck=0` in the generated .repo file is the marker for that being
26 + # unfinished, not a decision.
27 + set -euo pipefail
28 +
29 + HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
30 + REPO_ROOT="$(cd "$HERE/../.." && pwd)"
31 +
32 + # shellcheck source=build/privilege.sh
33 + . "$REPO_ROOT/build/privilege.sh"
34 +
35 + die() { printf 'error: %s\n' "$*" >&2; exit 1; }
36 + say() { printf '%s\n' "$*"; }
37 +
38 + BINARY=""
39 + OUT="$HERE/out"
40 + while [ $# -gt 0 ]; do
41 + case "$1" in
42 + --binary) BINARY="${2:-}"; shift 2 ;;
43 + --out) OUT="${2:-}"; shift 2 ;;
44 + *) die "unknown argument $1" ;;
45 + esac
46 + done
47 +
48 + # The version comes from the crate and from nowhere else. A packaging script
49 + # that took its own --version argument is how an RPM ends up claiming a version
50 + # the binary inside it does not report, and `alloy --version` is what a person
51 + # quotes in a bug report.
52 + VERSION="$(grep -m1 '^version' "$REPO_ROOT/crates/alloy/Cargo.toml" | cut -d'"' -f2)"
53 + [ -n "$VERSION" ] || die "no version in crates/alloy/Cargo.toml"
54 + [ "$VERSION" != "0.0.0" ] || die "the console is still at 0.0.0; see GO 8c5e2838"
55 +
56 + if [ -z "$BINARY" ]; then
57 + say "building the console $VERSION"
58 + # No --locked, unlike the Containerfile, and the difference is the build
59 + # location rather than a difference in intent. This runs on the dev host,
60 + # inside ~/Code, where `.cargo/config.toml`'s `[patch]` block redirects the
61 + # cross-repo git deps to the working copies; under it every resolve rewrites
62 + # the lock, so --locked fails on every machine that has the redirect rather
63 + # than catching anything (CLAUDE.md states this tree-wide). The Containerfile
64 + # builds in a container that has no such block, which is why it can pass it.
65 + #
66 + # What that costs: a hotfix built here takes whatever the sibling working
67 + # copies currently hold, so build it from a clean tree, and prefer handing in
68 + # a binary from the image build with --binary when the two must match.
69 + (cd "$REPO_ROOT" && cargo build --release -p alloy)
70 + BINARY="$REPO_ROOT/target/release/alloy"
71 + fi
72 + [ -x "$BINARY" ] || die "no console binary at $BINARY"
73 +
74 + # Checked rather than trusted. Packaging a binary whose version disagrees with
75 + # the spec's is the one mistake this script exists to make impossible, and it
76 + # is invisible afterwards: the RPM installs, the machine reports the version
77 + # rpm knows, and the binary reports another.
78 + REPORTED="$("$BINARY" --version 2>/dev/null | head -1 | awk '{print $2}')"
79 + [ "$REPORTED" = "$VERSION" ] \
80 + || die "binary reports $REPORTED, Cargo.toml says $VERSION"
81 +
82 + mkdir -p "$OUT"
83 + rm -rf "${OUT:?}/rpmbuild" "${OUT:?}/repo"
84 + mkdir -p "$OUT/rpmbuild/SOURCES" "$OUT/repo"
85 + install -m 0755 "$BINARY" "$OUT/rpmbuild/SOURCES/alloy"
86 +
87 + # In a container rather than on the host: rpmbuild and createrepo_c are not on
88 + # a dev box by default, and the package has to carry the same %{dist} as the
89 + # base it installs into. fedora:43 matches the image; a package built as .fc42
90 + # lands in a repo the machine will resolve and then refuses to be what anyone
91 + # asked for.
92 + say "packaging alloy-$VERSION"
93 + privc podman run --rm \
94 + -v "$HERE:/spec:ro,z" \
95 + -v "$OUT:/out:z" \
96 + -w /out \
97 + registry.fedoraproject.org/fedora:43 bash -c "
98 + set -e
99 + dnf -y install rpm-build createrepo_c >/dev/null 2>&1
100 + rpmbuild --define '_topdir /out/rpmbuild' --define 'alloy_version $VERSION' \
101 + -bb /spec/alloy.spec >/dev/null
102 + cp /out/rpmbuild/RPMS/*/*.rpm /out/repo/
103 + createrepo_c /out/repo >/dev/null
104 + " >/dev/null
105 +
106 + # podman writes as root through the bind mount, so the output is not the
107 + # invoking user's without this. Left as a separate step rather than folded into
108 + # the container command, which cannot know the uid outside it.
109 + privc chown -R "$(id -u):$(id -g)" "$OUT"
110 +
111 + say ""
112 + say "built:"
113 + find "$OUT/repo" -name '*.rpm' -printf ' %f\n'
114 + say ""
115 + say "repo metadata in $OUT/repo"
116 + say "serve that directory and point a machine at it with a .repo file:"
117 + say ""
118 + say " [alloy]"
119 + say " name=Alloy components"
120 + say " baseurl=https://<host>/rpm/"
121 + say " enabled=1"
122 + say " gpgcheck=0 # until signing lands"