The catalog's default version was {"version": "latest", image:
bitcoin-knots:29.3.knots20260508, default: true}, so a fresh install — or
anyone selecting "latest" — silently got the BIP110/RDTS build. Meanwhile
the manifest's own :latest tag resolves to 29.3.knots20260210 in the
registry, so the two paths installed different daemons.
That matters because 20260508 does not merely enable RDTS, it HALTS until
an operator sets consensusrules=rdts. Node 100.64.204.114 runs it and is
frozen at block 961,692 — blocks AND headers static across 30s, 11 peers,
unpruned — while reporting initialblockdownload=false and
verificationprogress 0.99998, i.e. "synced". The two nodes on 20260210 sit
at the tip, 961,706. Re-pointing a tag could have done that to every node
at once.
Pinned in all three places that would otherwise re-introduce a moving tag:
the manifest, image-versions.sh, and the catalog VERSIONS table. The
"latest" pseudo-version is removed outright — a moving default is not
appropriate for a consensus-critical app — and the default is now the
explicit non-RDTS 29.3.knots20260210. 20260508 remains selectable, so
adopting RDTS stays possible but becomes deliberate.
Verified: both tags' daemons read via `bitcoind --version` — :latest and
:29.3.knots20260210 are both v29.3.knots20260210, so this pin is
behaviour-neutral on nodes already running latest.
NOT DONE HERE: releases/app-catalog.json still carries the old signed
entry. It must be regenerated and re-signed by the operator before it
takes effect — catalog entries override on-disk manifests (origin-wins).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
251 lines
11 KiB
Bash
Executable File
251 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Generate releases/app-catalog.json — the REMOTE per-app version catalog that
|
|
# decouples app updates from the binary OTA (see
|
|
# core/.../container/app_catalog.rs and docs/dht-distribution-design.md).
|
|
#
|
|
# Nodes fetch this file over HTTP from the OVH origin (same host as the OTA
|
|
# manifest), compare each app's catalog version against the running container
|
|
# tag, and light up the per-app "Update" button — no node release required.
|
|
#
|
|
# The app_id -> image-variable mapping below MIRRORS
|
|
# core/archipelago/src/container/image_versions.rs (image_var_for_app +
|
|
# containers_for_stack). image_versions.rs is the canonical mapping; keep this in
|
|
# sync when you add an app there.
|
|
#
|
|
# Usage:
|
|
# scripts/generate-app-catalog.sh [output-path]
|
|
# EMBED_MANIFESTS=0 scripts/generate-app-catalog.sh # version/image only (legacy)
|
|
# # then publish: push releases/app-catalog.json to the OVH gitea (raw URL).
|
|
#
|
|
# EMBED_MANIFESTS (default ON, 2026-06-23): embed each app's full
|
|
# apps/<id>/manifest.yml into its catalog entry's `manifest` field, so nodes
|
|
# install from the signed registry alone (no OTA-shipped disk manifest). Consumed
|
|
# by container::app_catalog + the orchestrator's load_manifests overlay
|
|
# (origin-wins, disk = fallback). See docs/registry-manifest-design.md. The
|
|
# migration window is over — every regen now embeds; set EMBED_MANIFESTS=0 only
|
|
# to reproduce the old version/image-only catalog.
|
|
set -euo pipefail
|
|
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
OUT="${1:-$ROOT/releases/app-catalog.json}"
|
|
|
|
# Export every *_IMAGE var (and ARCHY_REGISTRY) so python can read them.
|
|
set -a
|
|
# shellcheck disable=SC1091
|
|
source "$ROOT/scripts/image-versions.sh"
|
|
set +a
|
|
|
|
UPDATED="$(date -u +%Y-%m-%d)" OUT="$OUT" APPS_DIR="$ROOT/apps" \
|
|
EMBED_MANIFESTS="${EMBED_MANIFESTS:-1}" python3 - <<'PY'
|
|
import glob
|
|
import json, os
|
|
|
|
try:
|
|
import yaml
|
|
except ImportError:
|
|
yaml = None
|
|
|
|
def img(var):
|
|
v = os.environ.get(var)
|
|
return v if v else None
|
|
|
|
def tag(image):
|
|
# version = tag after the LAST colon that follows the last slash
|
|
if not image:
|
|
return None
|
|
tail = image.rsplit('/', 1)[-1]
|
|
return tail.rsplit(':', 1)[1] if ':' in tail else 'latest'
|
|
|
|
# Single-container apps: app_id -> primary image variable.
|
|
SINGLE = {
|
|
"bitcoin-knots": "BITCOIN_KNOTS_IMAGE",
|
|
"lnd": "LND_IMAGE",
|
|
"electrumx": "ELECTRUMX_IMAGE",
|
|
"bitcoin-ui": "BITCOIN_UI_IMAGE",
|
|
"lnd-ui": "LND_UI_IMAGE",
|
|
"electrs-ui": "ELECTRS_UI_IMAGE",
|
|
"homeassistant": "HOMEASSISTANT_IMAGE",
|
|
"grafana": "GRAFANA_IMAGE",
|
|
"uptime-kuma": "UPTIME_KUMA_IMAGE",
|
|
"jellyfin": "JELLYFIN_IMAGE",
|
|
"photoprism": "PHOTOPRISM_IMAGE",
|
|
"ollama": "OLLAMA_IMAGE",
|
|
"vaultwarden": "VAULTWARDEN_IMAGE",
|
|
"nextcloud": "NEXTCLOUD_IMAGE",
|
|
"searxng": "SEARXNG_IMAGE",
|
|
"cryptpad": "CRYPTPAD_IMAGE",
|
|
"filebrowser": "FILEBROWSER_IMAGE",
|
|
"nginx-proxy-manager": "NPM_IMAGE",
|
|
"portainer": "PORTAINER_IMAGE",
|
|
"tailscale": "TAILSCALE_IMAGE",
|
|
"fedimint": "FEDIMINT_IMAGE",
|
|
"fedimint-gateway": "FEDIMINT_GATEWAY_IMAGE",
|
|
"nostr-rs-relay": "NOSTR_RS_RELAY_IMAGE",
|
|
"nostr-vpn": "NOSTR_VPN_IMAGE",
|
|
"fips": "FIPS_IMAGE",
|
|
"routstr": "ROUTSTR_IMAGE",
|
|
"adguardhome": "ADGUARDHOME_IMAGE",
|
|
}
|
|
|
|
# Stack apps: app_id -> {container_name: image variable}. The FIRST entry is the
|
|
# primary (its version drives the badge); it is also emitted as `image`.
|
|
STACK = {
|
|
"indeedhub": {
|
|
"indeedhub": "INDEEDHUB_IMAGE",
|
|
"indeedhub-api": "INDEEDHUB_API_IMAGE",
|
|
"indeedhub-ffmpeg": "INDEEDHUB_FFMPEG_IMAGE",
|
|
},
|
|
"immich": {
|
|
"immich_server": "IMMICH_SERVER_IMAGE",
|
|
"immich_postgres": "IMMICH_POSTGRES_IMAGE",
|
|
"immich_redis": "REDIS_IMAGE",
|
|
},
|
|
"penpot": {
|
|
"penpot-frontend": "PENPOT_FRONTEND_IMAGE",
|
|
"penpot-backend": "PENPOT_BACKEND_IMAGE",
|
|
"penpot-exporter": "PENPOT_EXPORTER_IMAGE",
|
|
"penpot-postgres": "PENPOT_POSTGRES_IMAGE",
|
|
"penpot-valkey": "PENPOT_VALKEY_IMAGE",
|
|
},
|
|
"mempool": {
|
|
"archy-mempool-web": "MEMPOOL_WEB_IMAGE",
|
|
"mempool-api": "MEMPOOL_BACKEND_IMAGE",
|
|
"archy-mempool-db": "MARIADB_IMAGE",
|
|
},
|
|
"btcpay": {
|
|
"btcpay-server": "BTCPAY_IMAGE",
|
|
"archy-nbxplorer": "NBXPLORER_IMAGE",
|
|
"archy-btcpay-db": "BTCPAY_POSTGRES_IMAGE",
|
|
},
|
|
}
|
|
|
|
apps = {}
|
|
for app_id, var in SINGLE.items():
|
|
image = img(var)
|
|
if image:
|
|
apps[app_id] = {"version": tag(image), "image": image}
|
|
|
|
for app_id, comps in STACK.items():
|
|
images = {name: img(var) for name, var in comps.items() if img(var)}
|
|
if not images:
|
|
continue
|
|
primary_name = next(iter(comps)) # first listed = primary
|
|
primary_image = img(comps[primary_name])
|
|
entry = {"version": tag(primary_image)}
|
|
if primary_image:
|
|
entry["image"] = primary_image
|
|
entry["images"] = images
|
|
apps[app_id] = entry
|
|
|
|
# Opt-in (EMBED_MANIFESTS): embed each app's full manifest so nodes install from
|
|
# the registry alone. The whole manifest document is embedded under `manifest`
|
|
# (top-level `app:` preserved) — that is exactly what the Rust side deserializes
|
|
# into an AppManifest. Apps not already in SINGLE/STACK get a new entry whose
|
|
# version comes from the manifest. A bad embed is harmless: the node validates and
|
|
# falls back to its disk manifest.
|
|
embedded = 0
|
|
apps_dir = os.environ.get("APPS_DIR")
|
|
if os.environ.get("EMBED_MANIFESTS") and apps_dir:
|
|
if yaml is None:
|
|
raise SystemExit("EMBED_MANIFESTS set but PyYAML is not available")
|
|
for path in sorted(glob.glob(os.path.join(apps_dir, "*", "manifest.yml"))):
|
|
with open(path) as fh:
|
|
data = yaml.safe_load(fh)
|
|
if not isinstance(data, dict) or not isinstance(data.get("app"), dict):
|
|
continue
|
|
app = data["app"]
|
|
app_id = app.get("id")
|
|
if not app_id:
|
|
continue
|
|
entry = apps.setdefault(str(app_id), {})
|
|
entry.setdefault("version", str(app.get("version", "")) or "0")
|
|
entry["manifest"] = data
|
|
embedded += 1
|
|
|
|
# Multi-version support (docs/bitcoin-multi-version-design.md §3 Phase 1):
|
|
# curated, bounded `versions[]` a runner may install or switch to. The entry
|
|
# marked default:true MUST equal the app's top-level catalog `version` (the
|
|
# manifest version for embedded apps) so selecting it un-pins / tracks latest.
|
|
#
|
|
# ONLY list versions whose tagged image is actually published to the registry —
|
|
# an unbuilt tag 404s on install. Extend each list as scripts/build-bitcoin-
|
|
# image.sh (Phase 0) publishes more tagged images, e.g.:
|
|
# {"version": "30.0", "image": f"{REGISTRY}/bitcoin:30.0"},
|
|
# {"version": "27.2", "image": f"{REGISTRY}/bitcoin:27.2", "deprecated": True, "eol": "2026-12-31"},
|
|
REGISTRY = os.environ.get("ARCHY_REGISTRY", "source.archipelago-foundation.org/lfg2025")
|
|
VERSIONS = {
|
|
# Curated Core set (latest patch per major, current → 25). Images built +
|
|
# verified (SHA-256 + OpenPGP, fail-closed) and pushed by
|
|
# scripts/build-bitcoin-image.sh. `28.4.0` is the default (== the manifest's
|
|
# top-level version) so existing/new installs are undisturbed; runners switch
|
|
# up to 31.0 (e.g. for BIP-110 signalling) or down to 25.2 from the app's
|
|
# "Version & Updates" card. Add the next release by building its image then
|
|
# prepending it here.
|
|
"bitcoin-core": [
|
|
{"version": "latest", "image": f"{REGISTRY}/bitcoin:latest", "default": True},
|
|
{"version": "31.0", "image": f"{REGISTRY}/bitcoin:31.0"},
|
|
{"version": "30.2", "image": f"{REGISTRY}/bitcoin:30.2"},
|
|
{"version": "29.3", "image": f"{REGISTRY}/bitcoin:29.3"},
|
|
{"version": "29.2", "image": f"{REGISTRY}/bitcoin:29.2"},
|
|
{"version": "28.4.0", "image": f"{REGISTRY}/bitcoin:28.4"},
|
|
{"version": "27.2", "image": f"{REGISTRY}/bitcoin:27.2"},
|
|
{"version": "26.2", "image": f"{REGISTRY}/bitcoin:26.2", "deprecated": True},
|
|
{"version": "25.2", "image": f"{REGISTRY}/bitcoin:25.2", "deprecated": True},
|
|
],
|
|
# Knots: a real tagged build is now published, so it's selectable + pinnable
|
|
# in the Knots app interface. `latest` is the default (== the manifest's
|
|
# floating tag) so selecting it un-pins / tracks latest — this MUST match the
|
|
# top-level catalog version (L167-168) or the card can't reach "latest" and
|
|
# selecting the highlighted default would instead pin+recreate. Pinning
|
|
# 29.3.knots20260508 moves a runner off the floating tag.
|
|
# `latest` is the default and points at the NEWEST published dated image
|
|
# (not the bare :latest tag) so "Always use the latest version" installs the
|
|
# newest build on fixed-binary nodes, while UNPINNED nodes still resolve via
|
|
# the manifest's floating :latest tag (kept on the legacy image until the
|
|
# entrypoint-render fix is fleet-deployed — see
|
|
# the bitcoin multi-version design).
|
|
# NO "latest" pseudo-version here, and the default is pinned. The entry
|
|
# marked default:true used to be {"version": "latest"} pointing at
|
|
# 29.3.knots20260508 — so a fresh install, or anyone picking "latest",
|
|
# silently got the BIP110/RDTS build. That build HALTS until an operator
|
|
# sets consensusrules=rdts: node 100.64.204.114 runs it and is frozen at
|
|
# block 961,692 (blocks AND headers static, 11 peers, unpruned) while
|
|
# reporting itself synced, whereas the nodes on 20260210 sit at the tip.
|
|
# A default that can move across a consensus boundary is a fleet-wide
|
|
# stall waiting to happen, so the default is an explicit, non-RDTS build
|
|
# and moving it is a deliberate consensus decision.
|
|
"bitcoin-knots": [
|
|
{"version": "29.3.knots20260210",
|
|
"image": f"{REGISTRY}/bitcoin-knots:29.3.knots20260210", "default": True},
|
|
{"version": "29.3.knots20260508",
|
|
"image": f"{REGISTRY}/bitcoin-knots:29.3.knots20260508"},
|
|
{"version": "29.3.knots20260507",
|
|
"image": f"{REGISTRY}/bitcoin-knots:29.3.knots20260507"},
|
|
{"version": "29.2.knots20251110",
|
|
"image": f"{REGISTRY}/bitcoin-knots:29.2.knots20251110"},
|
|
],
|
|
}
|
|
for app_id, versions in VERSIONS.items():
|
|
if app_id in apps and versions:
|
|
apps[app_id]["versions"] = versions
|
|
# The default/latest entry MUST equal the app's top-level catalog
|
|
# `version` (commit 169ff2e2) so selecting the highlighted default
|
|
# un-pins / tracks latest instead of pinning+recreating. Enforce it here
|
|
# rather than relying on the manifest version matching.
|
|
default_entry = next((v for v in versions if v.get("default")), None)
|
|
if default_entry:
|
|
apps[app_id]["version"] = default_entry["version"]
|
|
|
|
catalog = {
|
|
"schema": 1,
|
|
"updated": os.environ["UPDATED"],
|
|
"apps": dict(sorted(apps.items())),
|
|
}
|
|
|
|
with open(os.environ["OUT"], "w") as f:
|
|
json.dump(catalog, f, indent=2)
|
|
f.write("\n")
|
|
suffix = f" (embedded {embedded} manifests)" if embedded else ""
|
|
print(f"Wrote {os.environ['OUT']} with {len(apps)} apps{suffix}")
|
|
PY
|