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