Merge pull request 'The cache signing key is installed, and its DNS was never a dashboard click' (#318) from worktree-nix-binary-cache into main
ci / web (push) Successful in 1m15s
ci / rust-arm64 (push) Failing after 1m45s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 16s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
ci / docs-site (push) Successful in 1m18s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 17s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 17s
ci / bun-nix (push) Successful in 1m35s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
ci / rust (push) Failing after 4m9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m25s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 2m35s
docker / builders-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Successful in 32s
nix / flake (push) Failing after 18m59s

Reviewed-on: #318
This commit was merged in pull request #318.
This commit is contained in:
2026-08-18 22:04:09 +00:00
3 changed files with 179 additions and 174 deletions
+1 -1
View File
@@ -100,7 +100,7 @@ gamescope from source — about an hour. With it you get prebuilt binaries:
```nix
nix.settings = {
substituters = [ "https://nix.unom.io" ];
trusted-public-keys = [ "punktfunk-cache-1:<key>" ]; # curl https://nix.unom.io/punktfunk-cache.pub
trusted-public-keys = [ "punktfunk-cache-1:yhOJmHxzg6tzXpxSFzlYn6Pc6r0jHprsWqt8MZC654o=" ]; # curl https://nix.unom.io/punktfunk-cache.pub
};
```
+45 -34
View File
@@ -64,7 +64,7 @@ as usual, so the cache is deliberately small and adding it costs you nothing on
```nix
nix.settings = {
substituters = [ "https://nix.unom.io" ];
trusted-public-keys = [ "punktfunk-cache-1:<PUBLIC KEY see below>" ];
trusted-public-keys = [ "punktfunk-cache-1:yhOJmHxzg6tzXpxSFzlYn6Pc6r0jHprsWqt8MZC654o=" ];
};
```
@@ -72,7 +72,7 @@ nix.settings = {
```conf
extra-substituters = https://nix.unom.io
extra-trusted-public-keys = punktfunk-cache-1:<PUBLIC KEY — see below>
extra-trusted-public-keys = punktfunk-cache-1:yhOJmHxzg6tzXpxSFzlYn6Pc6r0jHprsWqt8MZC654o=
```
The current public key is served by the cache itself, so you can always check it against the source
@@ -467,48 +467,59 @@ service up *before* you set the secret that switches publishing on. The secret i
exactly that reason: until it exists the publish no-ops with a warning and `main` stays green,
the same way flatpak.yml's repo deploy does.
1. **DNS:** `nix.unom.io` → the unom-1 hcloud box, in the `unom.io` Cloudflare zone, **DNS-only**
(not proxied) — same as `docs` and `winget`.
2. **Caddy vhost:** in **`unom/infra`, `caddy/Caddyfile`**, next to the existing
`docs.punktfunk.unom.io` block:
1. **Ingress — both halves live in `unom/infra`, and they must move together.** Neither the DNS
record nor the vhost is a click: `terraform/cloudflare/records.tf` owns the zone and
`caddy/Caddyfile` owns the vhosts, and that file says so itself — *"a name here with no vhost
404s, a vhost with no name here never cuts over."*
```caddyfile
nix.unom.io {
import security_headers
reverse_proxy localhost:3250
}
```
- `terraform/cloudflare/records.tf` — add `"nix"` to `local.hostnames`. It inherits
`proxied = false`, which this service specifically needs: a proxy that masked the origin's
404s would fail users' builds for every package the cache does not hold.
- `caddy/Caddyfile` — next to the `docs.punktfunk.unom.io` block:
⚠ **Not by hand on the box.** `~/caddy/Caddyfile` on unom-1 looks like the config but is a copy
that `deploy-all.sh` rsyncs over from `unom/infra`, with no `.git` there to warn you — a vhost
added only on the box survives until the next deploy and no longer (this bit the winget source
on 2026-07-26; see `packaging/winget/server/README.md` for the incident).
```caddyfile
nix.unom.io {
import security_headers
reverse_proxy localhost:3250
}
```
Until the vhost exists the hostname resolves but the TLS handshake fails, because Caddy has no
certificate for a name it does not serve. Expected on first setup — and also exactly how a later
clobber presents. Diagnose by SNI, not by port 80 (Caddy 308s every Host to https, including
names it has never heard of, so a redirect proves nothing):
Apply with the **`dns-cutover.yml`** workflow (`target=hcloud`, `action=plan` first — expect a
single added `cloudflare_record.a["nix"]`, stop if it shows anything else) and `deploy-all` for
the Caddyfile.
⚠ **Neither by hand.** A record added in the Cloudflare dashboard is out-of-band and risks the
duplicate-record round-robin `records.tf` documents; `~/caddy/Caddyfile` on unom-1 looks like the
config but is a copy `deploy-all.sh` rsyncs from the repo, with no `.git` to warn you — a vhost
added only on the box survives until the next deploy and no longer (this bit the winget source on
2026-07-26; see `packaging/winget/server/README.md`).
Until both land the hostname fails the TLS handshake, because Caddy has no certificate for a name
it does not serve. Expected on first setup — and also exactly how a later clobber presents.
Diagnose by SNI, not by port 80 (Caddy 308s every Host to https, including names it has never
heard of, so a redirect proves nothing):
```sh
openssl s_client -connect nix.unom.io:443 -servername nix.unom.io </dev/null 2>&1 \
| grep -E '^subject=|alert'
```
3. Dispatch `deploy-services.yml` (or `unom/infra`'s `deploy-all`) to bring the container up. It
2. Dispatch `deploy-services.yml` (or `unom/infra`'s `deploy-all`) to bring the container up. It
serves an empty cache — every path 404s, which is exactly what a healthy empty cache does.
4. Generate the signing key and store the secret half as the repo Actions secret
`NIX_CACHE_SIGNING_KEY` (the whole `name:base64` line). On a Nix box:
```sh
nix key generate-secret --key-name punktfunk-cache-1
```
Or without one, on anything with docker:
```sh
docker run --rm nixos/nix nix --extra-experimental-features nix-command \
key generate-secret --key-name punktfunk-cache-1
```
5. Push to `main` touching the flake. The publish step prints the **public** key — paste it into the
"Binary cache" section above (and `docs-site/content/docs/install.md`) and commit.
3. **Signing key — done.** `NIX_CACHE_SIGNING_KEY` is installed as a repo Actions secret, and its
public half is pinned in the "Binary cache" section above and in
`docs-site/content/docs/install.md`. Regenerate only deliberately: a new key invalidates every
signature already published, and every user pinning the old one starts failing. If you ever must:
`scripts/setup-nix-cache.sh` walks through all five interactively.
```sh
nix key generate-secret --key-name punktfunk-cache-1 # on a Nix box
docker run --rm nixos/nix nix --extra-experimental-features nix-command \
key generate-secret --key-name punktfunk-cache-1 # or anywhere with docker
```
4. Push to `main` touching the flake. The publish step also writes the public key to
`https://nix.unom.io/punktfunk-cache.pub`, so users can always check the docs against the cache.
`scripts/setup-nix-cache.sh` walks through it interactively, and each stage detects work already
done — so it is safe to run now that the key exists.
**Operational notes:**
+133 -139
View File
@@ -182,31 +182,38 @@ finish() {
# ──────────────────────────────────────────────────────────────────────────
# STAGES — bring the punktfunk Nix binary cache at https://nix.unom.io live.
#
# Everything here is a step only a human can take: a Cloudflare record, a vhost in another
# repo, a Gitea secret. The wizard opens each page, says exactly what to click, and then
# VERIFIES the result before moving on — each stage has a distinct failure signature and
# they are easy to confuse (see the notes in each).
# Everything here is a step only a human can take: merging an infra PR, running an apply,
# dispatching a deploy. The wizard opens each page, says exactly what to do, and then
# VERIFIES the result before moving on — the failure signatures are easy to confuse:
#
# Safe to re-run: every stage detects work already done and offers to skip.
# TLS handshake failure -> the vhost is not applied (Caddy has no cert for that name)
# 502 / 503 -> vhost fine, the container behind :3250 is not running
# 404 -> healthy, the cache is simply empty
# 200 -> serving content
#
# Safe to re-run: every stage detects work already done and skips it.
# Full context: packaging/nix/README.md § "Cache infrastructure (maintainers)".
# ──────────────────────────────────────────────────────────────────────────
TOTAL_STAGES=5
TOTAL_STAGES=4
CACHE_HOST="nix.unom.io"
CACHE_PORT=3250
KEY_NAME="punktfunk-cache-1"
GITEA_REPO="https://git.unom.io/unom/punktfunk"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
GITEA="https://git.unom.io"
GITEA_REPO="$GITEA/unom/punktfunk"
INFRA_REPO="$GITEA/unom/infra"
# nix_run ARGS… — run a new-style nix command, from a local nix if there is one, otherwise
# from the official image. Keeps this wizard usable on a machine with no nix (the maintainer
# box is macOS).
nix_run() {
# probe PATH — HTTP status for a path on the cache, or 000 if it cannot be reached at all.
probe() { curl -sS -o /dev/null -w '%{http_code}' -m 15 "https://$CACHE_HOST$1" 2>/dev/null || echo 000; }
# nix_key ARGS… — run `nix key …` from a local nix if there is one, otherwise from the
# official image. Keeps this usable on a machine with no nix (the maintainer box is macOS).
nix_key() {
if command -v nix >/dev/null 2>&1; then
nix --extra-experimental-features nix-command "$@"
nix --extra-experimental-features nix-command key "$@"
elif command -v docker >/dev/null 2>&1; then
docker run --rm -i nixos/nix nix --extra-experimental-features nix-command "$@"
docker run --rm -i nixos/nix nix --extra-experimental-features nix-command key "$@"
else
return 127
fi
@@ -215,68 +222,55 @@ nix_run() {
banner "punktfunk Nix binary cache — bring-up"
# ── 1 ─────────────────────────────────────────────────────────────────────
stage "DNS — point $CACHE_HOST at unom-1"
stage "Ingress — DNS + the Caddy vhost (unom/infra)"
say "Both halves live in unom/infra and must move together: terraform/cloudflare/records.tf"
say "owns the DNS record, caddy/Caddyfile owns the vhost. That file's own rule:"
note " \"a name here with no vhost 404s, a vhost with no name here never cuts over.\""
printf '\n'
warn "Neither is a click."
note " A record added in the Cloudflare dashboard is out-of-band and risks the duplicate-record"
note " round-robin records.tf documents. ~/caddy/Caddyfile on unom-1 looks like the config but is"
note " a copy deploy-all.sh rsyncs from the repo — a vhost added there lasts until the next deploy."
printf '\n'
# Derive the target from a sibling service rather than hardcoding an IP that can move.
TARGET_IP="$(dig +short flatpak.unom.io | tail -n1)"
[[ -n "$TARGET_IP" ]] || TARGET_IP="167.233.145.172"
[ -n "$TARGET_IP" ] || TARGET_IP="167.233.145.172"
CURRENT="$(dig +short "$CACHE_HOST" | tail -n1)"
if [[ "$CURRENT" == "$TARGET_IP" ]]; then
say "$CACHE_HOST already resolves to $TARGET_IP — nothing to do."
pause "Press Enter for the next stage"
else
say "unom-1 (the hcloud box) is $TARGET_IP — the same address flatpak.unom.io uses."
open_url "https://dash.cloudflare.com/?to=/:account/unom.io/dns/records"
step "Add record → Type: A"
step "Name: nix (Cloudflare appends the zone, giving $CACHE_HOST)"
step "IPv4 address: $TARGET_IP"
step "Proxy status: DNS only — click the orange cloud so it turns GREY."
warn "Proxied (orange) would break large NAR downloads and mask the origin's 404s,"
note " which nix needs in order to treat a miss as a miss rather than a hard error."
step "Save."
pause "Saved? Press Enter to verify"
for _ in 1 2 3 4 5 6 7 8 9 10; do
if [ -n "$CURRENT" ]; then
printf ' %s✓%s %s already resolves to %s\n' "$GREEN" "$RESET" "$CACHE_HOST" "$CURRENT"
[ "$CURRENT" = "$TARGET_IP" ] || warn "expected $TARGET_IP (where flatpak.unom.io points) — check for a stale duplicate record"
else
open_url "$INFRA_REPO/pulls"
step "Merge the 'Serve nix.unom.io' PR (adds \"nix\" to local.hostnames + the vhost)."
step "Run dns-cutover.yml with target=hcloud, action=plan."
step "The plan must show exactly ONE added record: cloudflare_record.a[\"nix\"]."
warn "If it shows anything else, stop — that zone config is shared with every unom site."
step "Re-run it with action=apply."
step "Then run deploy-all so the box picks up the new Caddyfile."
pause "Applied? Press Enter to verify DNS"
i=0
while [ "$i" -lt 10 ]; do
CURRENT="$(dig +short "$CACHE_HOST" | tail -n1)"
[[ -n "$CURRENT" ]] && break
printf ' %swaiting for DNS to propagate…%s\n' "$DIM" "$RESET"
sleep 6
[ -n "$CURRENT" ] && break
printf ' %swaiting for DNS (TTL is 300s)…%s\n' "$DIM" "$RESET"
sleep 10
i=$((i + 1))
done
if [[ "$CURRENT" == "$TARGET_IP" ]]; then
printf ' %s✓%s %s %s\n' "$GREEN" "$RESET" "$CACHE_HOST" "$CURRENT"
elif [[ -n "$CURRENT" ]]; then
warn "$CACHE_HOST resolves to $CURRENT, expected $TARGET_IP."
note " If that is a Cloudflare address (104.x / 172.6x), the record is PROXIED — grey the cloud."
confirm "Continue anyway?" || exit 1
if [ -n "$CURRENT" ]; then
printf ' %s✓%s %s -> %s\n' "$GREEN" "$RESET" "$CACHE_HOST" "$CURRENT"
else
warn "$CACHE_HOST still does not resolve."
SKIPPED+=("DNS record for $CACHE_HOST")
SKIPPED+=("DNS record for $CACHE_HOST (unom/infra records.tf + dns-cutover apply)")
confirm "Continue anyway?" || exit 1
fi
fi
# ── 2 ─────────────────────────────────────────────────────────────────────
stage "Caddy vhost — terminate TLS for $CACHE_HOST"
say "The edge Caddy runs on unom-1 itself, and its config is VERSION CONTROLLED in unom/infra."
printf '\n'
warn "Do NOT edit ~/caddy/Caddyfile on the box."
note " That file looks like the config but is a copy deploy-all.sh rsyncs over from unom/infra,"
note " with no .git there to warn you. A vhost added only on the box survives until the next"
note " deploy and no longer — that is how the winget source vanished on 2026-07-26."
printf '\n'
say "Add this next to the existing docs.punktfunk.unom.io block, in caddy/Caddyfile:"
printf '\n'
printf ' %s%s {\n import security_headers\n reverse_proxy localhost:%s\n }%s\n\n' \
"$DIM" "$CACHE_HOST" "$CACHE_PORT" "$RESET"
open_url "https://git.unom.io/unom/infra"
step "Edit caddy/Caddyfile, add the block above, commit and push."
step "Apply it the way unom/infra normally deploys (deploy-all)."
pause "Applied? Press Enter to verify the certificate"
# Diagnose by SNI. Caddy 308s EVERY Host on :80 to https, including names it has never heard
# of, so a redirect there proves nothing at all.
# The certificate is the proof the vhost half landed. Diagnose by SNI: Caddy 308s EVERY Host
# on :80 to https, including names it has never heard of, so probing port 80 proves nothing.
printf ' %schecking for a certificate…%s\n' "$DIM" "$RESET"
TLS_OUT="$(openssl s_client -connect "${CACHE_HOST}:443" -servername "$CACHE_HOST" \
</dev/null 2>&1 | grep -E '^subject=|alert' | head -n3 || true)"
if printf '%s' "$TLS_OUT" | grep -q '^subject='; then
@@ -284,131 +278,131 @@ if printf '%s' "$TLS_OUT" | grep -q '^subject='; then
else
warn "No certificate for $CACHE_HOST yet:"
printf ' %s%s%s\n' "$DIM" "${TLS_OUT:-(no response)}" "$RESET"
note " Expected before the vhost is applied — Caddy cannot present a cert for a name it"
note " does not serve. If you DID apply it, the deploy likely never reached the box."
SKIPPED+=("Caddy vhost for $CACHE_HOST in unom/infra caddy/Caddyfile")
note " Caddy issues one automatically once the name resolves AND the vhost is deployed."
note " If DNS is good, the Caddyfile half has not reached the box — re-run deploy-all."
SKIPPED+=("Caddy vhost for $CACHE_HOST")
confirm "Continue anyway?" || exit 1
fi
# ── 3 ─────────────────────────────────────────────────────────────────────
# ── 2 ─────────────────────────────────────────────────────────────────────
stage "Start the cache container on unom-1"
say "deploy-services.yml places the compose file + Caddyfile + prune.sh and starts the"
say "container on port $CACHE_PORT. It serves an EMPTY cache until the first publish."
open_url "$GITEA_REPO/actions?workflow=deploy-services.yml"
step "Run workflow → leave the input blank → Run."
step "Wait for the nix-cache job to go green."
pause "Green? Press Enter to verify"
CODE="$(probe /nix-cache-info)"
if [ "$CODE" = 404 ] || [ "$CODE" = 200 ]; then
printf ' %s✓%s Container already answering (HTTP %s)\n' "$GREEN" "$RESET" "$CODE"
else
say "deploy-services.yml ships the compose file + Caddyfile + prune.sh and starts the"
say "container on port $CACHE_PORT. It serves an EMPTY cache until the first publish."
open_url "$GITEA_REPO/actions?workflow=deploy-services.yml"
step "Run workflow -> leave the input blank -> Run."
step "Wait for the nix-cache job to go green."
pause "Green? Press Enter to verify"
CODE="$(curl -sS -o /dev/null -w '%{http_code}' -m 15 "https://$CACHE_HOST/nix-cache-info" 2>/dev/null || echo 000)"
case "$CODE" in
404)
printf ' %s✓%s Cache is up and empty — 404 on every path, exactly right for an empty cache\n' "$GREEN" "$RESET" ;;
200)
printf ' %s✓%s Cache is up and already holds content\n' "$GREEN" "$RESET" ;;
502|503)
warn "Caddy answered $CODE — the vhost is live but nothing is listening on :$CACHE_PORT."
note " Check the nix-cache job in deploy-services.yml, or docker compose ps on unom-1."
SKIPPED+=("cache container on unom-1:$CACHE_PORT")
confirm "Continue anyway?" || exit 1 ;;
*)
warn "Unexpected response ($CODE) from https://$CACHE_HOST/nix-cache-info"
SKIPPED+=("cache container on unom-1:$CACHE_PORT")
confirm "Continue anyway?" || exit 1 ;;
esac
CODE="$(probe /nix-cache-info)"
case "$CODE" in
404) printf ' %s✓%s Up and empty — 404 on every path, exactly right for an empty cache\n' "$GREEN" "$RESET" ;;
200) printf ' %s✓%s Up and already holding content\n' "$GREEN" "$RESET" ;;
502|503)
warn "Caddy answered $CODE — the vhost is live but nothing is listening on :$CACHE_PORT."
note " Check the nix-cache job, or docker compose ps on unom-1."
SKIPPED+=("cache container on unom-1:$CACHE_PORT")
confirm "Continue anyway?" || exit 1 ;;
*)
warn "Unexpected response ($CODE) from https://$CACHE_HOST/nix-cache-info"
SKIPPED+=("cache container on unom-1:$CACHE_PORT")
confirm "Continue anyway?" || exit 1 ;;
esac
fi
# ── 4 ─────────────────────────────────────────────────────────────────────
stage "Signing key — generate it, store it, pin it"
# ── 3 ─────────────────────────────────────────────────────────────────────
stage "Signing key"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
README_MD="$REPO_ROOT/packaging/nix/README.md"
INSTALL_MD="$REPO_ROOT/docs-site/content/docs/install.md"
if ! grep -q "$KEY_NAME:<" "$README_MD" 2>/dev/null; then
say "The docs already carry a real public key, so a key has been generated before."
warn "A new key would invalidate every signature already published."
if ! confirm "Generate a REPLACEMENT key anyway?"; then
say "Keeping the existing key."
SKIP_KEY=1
pause "Press Enter for the last stage"
fi
fi
if [[ "${SKIP_KEY:-0}" != 1 ]]; then
say "Generating an ed25519 signing key pair…"
SECRET_KEY="$(nix_run key generate-secret --key-name "$KEY_NAME" 2>/dev/null || true)"
if [[ -z "$SECRET_KEY" ]]; then
if grep -q "$KEY_NAME:<" "$README_MD" 2>/dev/null; then
say "The docs still carry a placeholder, so no key has been installed yet."
say "Generating an ed25519 key pair…"
SECRET_KEY="$(nix_key generate-secret --key-name "$KEY_NAME" 2>/dev/null || true)"
if [ -z "$SECRET_KEY" ]; then
warn "Could not run nix here (no local nix, and no docker to fall back to)."
say "Generate it on any Nix box and paste it back:"
note " nix key generate-secret --key-name $KEY_NAME"
ask_secret SECRET_KEY "Paste the secret key line:"
fi
if [[ -z "$SECRET_KEY" ]]; then
SKIPPED+=("NIX_CACHE_SIGNING_KEY secret + public key in the docs")
if [ -z "$SECRET_KEY" ]; then
SKIPPED+=("NIX_CACHE_SIGNING_KEY + the public key in the docs")
else
PUBLIC_KEY="$(printf '%s' "$SECRET_KEY" | nix_run key convert-secret-to-public 2>/dev/null || true)"
printf '\n %sSecret key — paste this into Gitea now; nothing here keeps a copy:%s\n\n' "$BOLD" "$RESET"
PUBLIC_KEY="$(printf '%s' "$SECRET_KEY" | nix_key convert-secret-to-public 2>/dev/null || true)"
printf '\n %sSecret key — paste into Gitea now; nothing here keeps a copy:%s\n\n' "$BOLD" "$RESET"
printf ' %s\n\n' "$SECRET_KEY"
open_url "$GITEA_REPO/settings/actions/secrets"
step "Add Secret Name: NIX_CACHE_SIGNING_KEY"
step "Add Secret -> Name: NIX_CACHE_SIGNING_KEY"
step "Value: the whole line above, including the '$KEY_NAME:' prefix."
step "Add Secret."
warn "This is the only copy — it is never written to disk."
pause "Stored? Press Enter"
WRITTEN_SECRET+=("NIX_CACHE_SIGNING_KEY (Gitea)")
SECRET_KEY=""
if [[ -n "$PUBLIC_KEY" ]]; then
printf '\n %sPublic key%s — this is what users pin:\n\n %s\n\n' "$BOLD" "$RESET" "$PUBLIC_KEY"
# Both docs carry a `punktfunk-cache-1:<…>` placeholder. Fill them in now rather than
# making someone wait an hour for the first publish to print the same value.
if [ -n "$PUBLIC_KEY" ]; then
printf '\n %sPublic key%s — what users pin:\n\n %s\n\n' "$BOLD" "$RESET" "$PUBLIC_KEY"
for f in "$README_MD" "$INSTALL_MD"; do
[[ -f "$f" ]] || continue
[ -f "$f" ] || continue
tmp="$(mktemp)"
sed "s|${KEY_NAME}:<[^>]*>|${PUBLIC_KEY}|g" "$f" > "$tmp" && mv "$tmp" "$f"
printf ' %s✓ pinned in%s %s\n' "$GREEN" "$RESET" "${f#"$REPO_ROOT"/}"
done
printf '\n'
say "Commit those two files — without the key nobody can trust the cache."
else
warn "Could not derive the public key; the first publish prints it in its log."
SKIPPED+=("public key in packaging/nix/README.md + docs-site install.md")
fi
pause "Press Enter for the last stage"
fi
else
PUBLIC_KEY="$(grep -om1 "$KEY_NAME:[A-Za-z0-9+/=]*" "$README_MD" 2>/dev/null || true)"
printf ' %s✓%s A key is already installed and pinned in the docs\n' "$GREEN" "$RESET"
[ -n "$PUBLIC_KEY" ] && printf ' %s\n' "$PUBLIC_KEY"
printf '\n'
warn "Do not regenerate it casually."
note " A new key invalidates every signature already published, and every user pinning the"
note " old one starts failing. Rotating means updating the docs and telling users."
pause "Press Enter for the last stage"
fi
# ── 5 ─────────────────────────────────────────────────────────────────────
# ── 4 ─────────────────────────────────────────────────────────────────────
stage "Publish — land the flake on main and verify"
say "The publish tier runs on a push to main touching the flake, Cargo.*, or packaging/nix."
say "Merging the cache PR is exactly such a push, so it publishes on merge."
printf '\n'
note " It builds the whole Rust workspace AND gamescope inside the nix sandbox — sccache"
note " cannot reach in there, so budget roughly an hour for the first run."
note " If it reddens on 'Build the bun packages', that is the known intermittent OOM"
note " (exit 137) rather than a real break — re-run the job."
printf '\n'
open_url "$GITEA_REPO/pulls"
step "Merge the Nix binary cache PR."
open_url "$GITEA_REPO/actions?workflow=nix.yml"
step "Merge any outstanding cache PR, or push a flake-touching commit to main."
step "Watch the nix workflow's 'Sign + publish to nix.unom.io' step."
pause "Published? Press Enter to verify the live cache"
CODE="$(curl -sS -o /dev/null -w '%{http_code}' -m 15 "https://$CACHE_HOST/nix-cache-info" 2>/dev/null || echo 000)"
if [[ "$CODE" == 200 ]]; then
CODE="$(probe /nix-cache-info)"
if [ "$CODE" = 200 ]; then
printf ' %s✓%s nix-cache-info is being served\n' "$GREEN" "$RESET"
LIVE_PUB="$(curl -sS -m 15 "https://$CACHE_HOST/punktfunk-cache.pub" 2>/dev/null || true)"
[[ -n "$LIVE_PUB" ]] && printf ' %s✓%s published public key: %s\n' "$GREEN" "$RESET" "$LIVE_PUB"
# A cache that 200s on nix-cache-info but does not 404 a miss is the one failure mode that
# breaks USERS rather than us: nix reads any non-404 as a hard error, not as a cache miss.
MISS="$(curl -sS -o /dev/null -w '%{http_code}' -m 15 \
"https://$CACHE_HOST/0000000000000000000000000000000000.narinfo" 2>/dev/null || echo 000)"
if [[ "$MISS" == 404 ]]; then
if [ -n "$LIVE_PUB" ]; then
printf ' %s✓%s published key: %s\n' "$GREEN" "$RESET" "$LIVE_PUB"
if [ -n "${PUBLIC_KEY:-}" ] && [ "$LIVE_PUB" != "$PUBLIC_KEY" ]; then
warn "That does NOT match the key pinned in the docs:"
note " docs: ${PUBLIC_KEY}"
note " cache: ${LIVE_PUB}"
note " Users following the docs would reject everything this cache serves."
SKIPPED+=("public key mismatch between the docs and $CACHE_HOST")
fi
fi
# The one failure mode that breaks USERS rather than us: nix reads any non-404 as a hard
# error, not as a cache miss, so a miss MUST 404.
MISS="$(probe /0000000000000000000000000000000000.narinfo)"
if [ "$MISS" = 404 ]; then
printf ' %s✓%s a miss returns 404 — nix falls through to cache.nixos.org correctly\n' "$GREEN" "$RESET"
else
warn "a miss returns $MISS, not 404 — every user build would then fail on any package"
warn "this cache does not hold. Check for a proxy or auth layer in front of Caddy."
warn "a miss returns $MISS, not 404 — every user build would fail on any package this"
warn "cache does not hold. Check for a proxy or auth layer in front of Caddy."
SKIPPED+=("404-on-miss behaviour at $CACHE_HOST")
fi
else