Skip to main content

max / alloy

Pin the bases at Fedora's own registry, which keeps what quay deletes quay garbage-collects a digest once its tag moves off it. Three times that has taken the pinned base with it, most recently this morning, and each time the whole install path is broken for anyone without the layers cached, because Alloy is distributed as a builder. Measured 2026-08-25: every digest quay had dropped was still served by registry.fedoraproject.org, including the two the Containerfile was pinned to. fedora-bootc sha256:96bf9106 quay 404 registry.fedoraproject.org 200 fedora sha256:9aae5331 quay 404 registry.fedoraproject.org 200 fedora-bootc sha256:0fd80baf quay 404 registry.fedoraproject.org 200 fedora-bootc sha256:2266b67d quay 404 registry.fedoraproject.org 200 Same content, not a different image: `fedora-bootc:43` resolves to the same index digest at both, the index there carries arm64 and amd64 with reachable blobs, and podman pulls the pin into an empty store and reports the digest the Containerfile names. So this is a retention change, and the images already built stay valid. refresh-base-digests.sh loses its hardcoded registry, which its own header had flagged as a guess waiting to be wrong. References are full registry/repo:tag now, and the pull token is discovered from the 401 rather than assumed: quay demands a bearer token on a public manifest and Fedora's registry does not. Not the fix. Retention here is still somebody else's policy and nobody has measured how long it holds. The mirror ruled on 2026-08-09 is what makes it ours; this buys the time to build it without the tree sitting unbuildable. GO alloy ebf30337.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-26 00:03 UTC
Signed with PGP, not checked
Commit: 1bd2104729fcf813be33136206bf0071d7e0fdce
Parent: aa0dacd
4 files changed, +83 insertions, -39 deletions
M Containerfile +2 -2
@@ -42,7 +42,7 @@
42 42 #
43 43 # To move it: build/refresh-base-digests.sh, which resolves the tags and
44 44 # rewrites these two lines. Do that deliberately, not as drive-by maintenance.
45 - FROM quay.io/fedora/fedora:43@sha256:4432141107de445eae7bcb3b5b9d29d39136109cff407bcaf517109c84f789ef AS rust-build
45 + FROM registry.fedoraproject.org/fedora:43@sha256:4432141107de445eae7bcb3b5b9d29d39136109cff407bcaf517109c84f789ef AS rust-build
46 46
47 47 # No python3 any more. It was here for tools/vtrgb.py, which rendered the
48 48 # greeter's console palette; skelgen emits that table now, along with the rest
@@ -692,7 +692,7 @@
692 692 # Digest-pinned for the same reason as the build stage above, and it matters
693 693 # more here: this one is the operating system. See that comment for why the
694 694 # tag is kept beside the digest and why it is the multi-arch index.
695 - FROM quay.io/fedora/fedora-bootc:43@sha256:c1133226662ce7f79c2398bf6f86047ff37fd82e843dee4d5f9cb8f3dbb58c8d
695 + FROM registry.fedoraproject.org/fedora-bootc:43@sha256:c1133226662ce7f79c2398bf6f86047ff37fd82e843dee4d5f9cb8f3dbb58c8d
696 696
697 697 # =====================================================================
698 698 # The profile: client or server.
@@ -48,14 +48,31 @@
48 48 REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
49 49 CONTAINERFILE="$REPO_ROOT/Containerfile"
50 50
51 - # The images to keep pinned, as `repository:tag`. Both are quay.io; if a base
52 - # ever comes from elsewhere this needs a registry column rather than a guess.
51 + # The images to keep pinned, as full `registry/repository:tag` references.
52 + #
53 + # registry.fedoraproject.org rather than quay.io, and the reason is retention.
54 + # Measured 2026-08-25, after the third time a quay pin died: every digest quay
55 + # had garbage-collected was still served here, including the two the
56 + # Containerfile was pinned to that morning.
57 + #
58 + # fedora-bootc sha256:96bf9106 quay 404 registry.fedoraproject.org 200
59 + # fedora sha256:9aae5331 quay 404 registry.fedoraproject.org 200
60 + # fedora-bootc sha256:0fd80baf quay 404 registry.fedoraproject.org 200
61 + # fedora-bootc sha256:2266b67d quay 404 registry.fedoraproject.org 200
62 + #
63 + # It is the same content: `fedora-bootc:43` resolves to the same index digest at
64 + # both, and the index there carries all four architectures with its blobs
65 + # reachable. So this is a retention change and not a content change, which is
66 + # why it could be made without rebuilding anything.
67 + #
68 + # It is not the fix. Retention here is still somebody else's policy, unmeasured
69 + # past a few weeks, and the mirror ruled on 2026-08-09 (GO alloy ebf30337) is
70 + # what makes it ours. This buys the time to build that without the tree being
71 + # unbuildable in the meantime.
53 72 IMAGES=(
54 - "fedora/fedora:43"
55 - "fedora/fedora-bootc:43"
73 + "registry.fedoraproject.org/fedora:43"
74 + "registry.fedoraproject.org/fedora-bootc:43"
56 75 )
57 -
58 - REGISTRY="quay.io"
59 76 CHECK_ONLY=0
60 77
61 78 # Exit 3, never 1: 1 is reserved for "a pin is dead", and a caller that cannot
@@ -88,16 +105,36 @@
88 105 # amd64-only. That failure would not show up until someone built on astra.
89 106 ACCEPT='Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
90 107
91 - # An anonymous pull token. quay.io serves public repos without credentials but
92 - # still wants a bearer token on the manifest endpoint.
108 + # An anonymous pull token, or nothing when the registry does not ask for one.
109 + #
110 + # Registries differ here and the difference is not cosmetic: quay.io serves
111 + # public repos without credentials but still demands a bearer token on the
112 + # manifest endpoint, while registry.fedoraproject.org answers a plain GET. So
113 + # the token is discovered rather than assumed — ask once, and only chase a
114 + # token if the registry replies 401 and says where to get one. Hardcoding
115 + # either behaviour breaks the moment a base moves to the other kind of
116 + # registry, which is exactly what happened to the version of this script that
117 + # hardcoded quay.
93 118 token_for() {
94 - local body
95 - body="$(curl -fsS "https://${REGISTRY}/v2/auth?service=${REGISTRY}&scope=repository:${1}:pull")" \
96 - || die "cannot reach ${REGISTRY} for a pull token on ${1}"
119 + local registry="$1" repo="$2" challenge realm service body
120 + challenge="$(curl -sSI "https://${registry}/v2/${repo}/manifests/latest" \
121 + | tr -d '\r' | grep -i '^www-authenticate:' || true)"
122 + case "$challenge" in
123 + *Bearer*) ;;
124 + *) printf ''; return 0 ;;
125 + esac
126 +
127 + realm="$(printf '%s' "$challenge" | grep -oE 'realm="[^"]+"' | cut -d'"' -f2)"
128 + service="$(printf '%s' "$challenge" | grep -oE 'service="[^"]+"' | cut -d'"' -f2)"
129 + [ -n "$realm" ] || die "${registry} asked for a token and named no realm"
130 +
131 + body="$(curl -fsS "${realm}?service=${service}&scope=repository:${repo}:pull")" \
132 + || die "cannot reach ${realm} for a pull token on ${repo}"
97 133 printf '%s' "$body" | python3 -c 'import sys, json; print(json.load(sys.stdin)["token"])' \
98 - || die "${REGISTRY} returned no usable pull token for ${1}"
134 + || die "${registry} returned no usable pull token for ${repo}"
99 135 }
100 136
137 +
101 138 # Whether a reference still resolves at the registry: prints `alive`, `dead`, or
102 139 # dies. This is the whole point of the check — a digest is immutable, so the only
103 140 # thing that can happen to one is that it stops existing.
@@ -106,40 +143,45 @@
106 143 # (the pin is gone) has to stay distinguishable from 500 (the registry is having
107 144 # a day). Only 200 and 404 are answers; anything else is an error about the run.
108 145 manifest_state() {
109 - local repo="$1" ref="$2" token status
110 - token="$(token_for "$repo")"
146 + local registry="$1" repo="$2" ref="$3" token status
147 + token="$(token_for "$registry" "$repo")"
111 148
112 - status="$(curl -sS -o /dev/null -w '%{http_code}' -I \
113 - -H "Authorization: Bearer ${token}" \
114 - -H "$ACCEPT" \
115 - "https://${REGISTRY}/v2/${repo}/manifests/${ref}")" \
116 - || die "cannot reach ${REGISTRY} for ${repo}"
149 + if [ -n "$token" ]; then
150 + status="$(curl -sS -o /dev/null -w '%{http_code}' -I \
151 + -H "Authorization: Bearer ${token}" -H "$ACCEPT" \
152 + "https://${registry}/v2/${repo}/manifests/${ref}")" \
153 + || die "cannot reach ${registry} for ${repo}"
154 + else
155 + status="$(curl -sS -o /dev/null -w '%{http_code}' -I -H "$ACCEPT" \
156 + "https://${registry}/v2/${repo}/manifests/${ref}")" \
157 + || die "cannot reach ${registry} for ${repo}"
158 + fi
117 159
118 160 case "$status" in
119 161 200) printf 'alive' ;;
120 162 404) printf 'dead' ;;
121 - *) die "${REGISTRY}/${repo} answered ${status} for ${ref}, which is neither" ;;
163 + *) die "${registry}/${repo} answered ${status} for ${ref}, which is neither" ;;
122 164 esac
123 165 }
124 166
125 167 # The index digest for a tag, asserted to be a real multi-arch index.
126 168 digest_for() {
127 - local repo="$1" tag="$2" token body digest
128 - token="$(token_for "$repo")"
169 + local registry="$1" repo="$2" tag="$3" token body digest
170 + token="$(token_for "$registry" "$repo")"
171 + local -a auth=()
172 + [ -z "$token" ] || auth=(-H "Authorization: Bearer ${token}")
129 173
130 - digest="$(curl -fsSI \
131 - -H "Authorization: Bearer ${token}" \
174 + digest="$(curl -fsSI "${auth[@]}" \
132 175 -H "$ACCEPT" \
133 - "https://${REGISTRY}/v2/${repo}/manifests/${tag}" \
176 + "https://${registry}/v2/${repo}/manifests/${tag}" \
134 177 | grep -i '^docker-content-digest:' | tr -d '\r' | awk '{print $2}')"
135 178
136 - [ -n "$digest" ] || die "no digest for ${repo}:${tag}"
179 + [ -n "$digest" ] || die "no digest for ${registry}/${repo}:${tag}"
137 180
138 181 # Prove it is an index over both build architectures before pinning it.
139 - body="$(curl -fsS \
140 - -H "Authorization: Bearer ${token}" \
182 + body="$(curl -fsS "${auth[@]}" \
141 183 -H "$ACCEPT" \
142 - "https://${REGISTRY}/v2/${repo}/manifests/${digest}")"
184 + "https://${registry}/v2/${repo}/manifests/${digest}")"
143 185
144 186 printf '%s' "$body" | python3 -c '
145 187 import json, sys
@@ -157,17 +199,19 @@
157 199 dead=0
158 200
159 201 for image in "${IMAGES[@]}"; do
160 - repo="${image%:*}"
161 - tag="${image#*:}"
162 - ref="${REGISTRY}/${repo}:${tag}"
202 + ref="${image}"
203 + registry="${image%%/*}"
204 + repo="${image#*/}"
205 + tag="${repo#*:}"
206 + repo="${repo%:*}"
163 207
164 208 current="$(grep -oE "^FROM ${ref}@sha256:[0-9a-f]{64}" "$CONTAINERFILE" | head -1 | grep -oE 'sha256:[0-9a-f]{64}' || true)"
165 209 [ -n "$current" ] || die "no digest-pinned FROM line for ${ref} in the Containerfile"
166 210
167 211 # The pin first, the tag second. Whether the tag advanced is interesting;
168 212 # whether the thing we pinned can still be pulled is the finding.
169 - state="$(manifest_state "$repo" "$current")"
170 - latest="$(digest_for "$repo" "$tag")"
213 + state="$(manifest_state "$registry" "$repo" "$current")"
214 + latest="$(digest_for "$registry" "$repo" "$tag")"
171 215
172 216 if [ "$state" = "dead" ]; then
173 217 dead=1
M docs/IMAGE.md +2 -2
@@ -26,7 +26,7 @@
26 26
27 27 Universal Blue's convention is exactly the bootc + Containerfile pattern above, using `FROM ghcr.io/ublue-os/main`. Battle-tested. But being downstream of ublue means inheriting ublue's opinions and coupling to their release cadence, which [STACK.md's Base section](STACK.md#base) explicitly rejects.
28 28
29 - **The nuance:** Alloy takes the ublue-style *convention* (a Containerfile over a bootc base) without ublue's base image: `FROM quay.io/fedora/fedora-bootc:43`, not `FROM ghcr.io/ublue-os/main`. That is what "alongside ublue, not downstream" resolves to in practice.
29 + **The nuance:** Alloy takes the ublue-style *convention* (a Containerfile over a bootc base) without ublue's base image: `FROM registry.fedoraproject.org/fedora-bootc:43`, not `FROM ghcr.io/ublue-os/main`. That is what "alongside ublue, not downstream" resolves to in practice.
30 30
31 31 This line used to say the convention included an OCI registry and a CI-driven build. It does not, and that is where Alloy parts company with ublue rather than a detail of it: ublue's whole model is a published image somebody else builds for you, and Alloy's is source you build yourself. quay.io appears in this repo in exactly two places, both `FROM` lines pulling Fedora's own bases, and nothing is ever pushed there.
32 32
@@ -96,7 +96,7 @@
96 96
97 97 The Containerfile at the repo root (see `Containerfile`) sketches:
98 98
99 - 1. **Base:** `FROM quay.io/fedora/fedora-bootc:43`.
99 + 1. **Base:** `FROM registry.fedoraproject.org/fedora-bootc:43`. Fedora's own registry rather than quay, and it is a retention choice: quay garbage-collects a digest the moment its tag moves off it, three times taking the pinned base with it and leaving the whole install path unbuildable. Every digest quay had dropped was still served at registry.fedoraproject.org when that was measured on 2026-08-25, and the tag resolves to the same index digest at both, so it is the same content with a longer memory. The mirror that makes retention ours is still owed (`build/refresh-base-digests.sh` says why).
100 100 2. **Third-party repos:** Tailscale, any COPRs Alloy depends on for packages not in Fedora main.
101 101 3. **Package additions:** the full Alloy stack from [STACK.md](STACK.md): compositor, bar, launcher, notifications, terminal, editor, shell, viewers, utilities, continuity daemons, fonts, themes.
102 102 4. **Package removals:** stock Silverblue desktop pieces Alloy replaces (gnome-shell, gdm; the latter gated on the greeter pick).
M docs/STACK.md +1 -1
@@ -8,7 +8,7 @@
8 8
9 9 **ublue relationship: alongside, not downstream.** Preserves naming and architectural independence. The "Alloy" name was picked specifically to avoid the ublue "Blue\*" prefix. Tradeoff: no shared image-building infrastructure, so whatever Alloy ships for image composition is on Alloy to build/maintain. Upside: total architectural freedom and a clean brand.
10 10
11 - **Composition strategy:** bootc + Containerfile, `FROM quay.io/fedora/fedora-bootc:43`. Source on `makenot.work/git/max/alloy`, built locally and natively by `build/build-image.sh`. This is the ublue *convention* (Containerfile) without ublue's base image, which is what "alongside ublue" resolves to in practice. Full delivery strategy in [IMAGE.md](IMAGE.md). Containerfile at the repo root.
11 + **Composition strategy:** bootc + Containerfile, `FROM registry.fedoraproject.org/fedora-bootc:43`. Source on `makenot.work/git/max/alloy`, built locally and natively by `build/build-image.sh`. This is the ublue *convention* (Containerfile) without ublue's base image, which is what "alongside ublue" resolves to in practice. Full delivery strategy in [IMAGE.md](IMAGE.md). Containerfile at the repo root.
12 12
13 13 **No image is published, and none will be.** Distribution is the builder, not the artifact: you get the source, configure it, build the ISO on your own machine, and write it to a medium. That is decided (2026-07-30) rather than pending, so `quay.io/alloy/alloy` is not a future address, there is no signing story to wait on, and the absence of hosted CI stops being a gap. An always-on x86_64 builder is still wanted for development and is no longer between Alloy and other people running it.
14 14