feat(release): attach the installer ISO to the Gitea release automatically

Publishing the ISO was a manual step printed as a reminder at the end of
build-iso-release.sh: upload the ISO, its .sha256 and the signed checksum
JSON by hand. Only the OTA binary and frontend tarball were automated.

publish-release-assets.sh now uploads all three when an ISO for the
version exists in image-recipe/results/, with the same supply-chain rules
the OTA manifest already gets: the checksum JSON must be signed by the
pinned release root, the signature must cryptographically verify, and the
image must still match its own .sha256 (a truncated or half-copied ISO is
exactly what a signed checksum exists to expose). After upload it
confirms every asset landed at its exact local size.

The stage runs AFTER main is pushed, deliberately. The ISO is not
referenced by releases/manifest.json, so no node's OTA path depends on
it — running it last means a slow or failed multi-GB upload can never
delay or strand an OTA release that has already been verified. When no
ISO exists yet (the usual case, since the ISO build needs the tag this
script pushes) it explains how to build and attach one, and exits clean.

Uploads take a max-time argument: 4h and a progress bar for the ISO,
where the previous fixed 15-minute silent ceiling would have killed a
multi-GB transfer partway through.

Verified with a stubbed harness: no-ISO skip, missing .sha256, unsigned
checksum, wrong signing key, corrupted image, happy path, and a truncated
upload caught by the size check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-20 01:33:54 -04:00
co-authored by Claude Opus 5
parent e79ab37da7
commit 5ccef0ac2f
2 changed files with 101 additions and 5 deletions
+4 -3
View File
@@ -193,7 +193,8 @@ echo " ISO: $ISO ($(du -h "$ISO" | cut -f1))"
echo " SHA256: $(cut -d' ' -f1 "$SHA_FILE")"
echo
echo " Next steps (publisher, offline mnemonic required):"
echo " 1. scripts/sign-iso-checksums.sh $ISO"
echo " 2. upload ISO + .sha256 + signed checksum JSON alongside the"
echo " v$VERSION Gitea release assets"
echo " 1. bash scripts/sign-iso-checksums.sh $ISO"
echo " 2. bash scripts/publish-release-assets.sh $VERSION"
echo " (attaches the ISO, its .sha256 and the signed checksum JSON to"
echo " the v$VERSION Gitea release, then verifies the stored sizes)"
summary 0
+97 -2
View File
@@ -100,15 +100,22 @@ fi
release_id=$(printf '%s' "$release_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
asset_names=$(curl -fsS -u "$auth" "$api/releases/$release_id/assets" | python3 -c 'import json,sys; print("\n".join(a["name"] for a in json.load(sys.stdin)))')
# upload_asset <path> <name> [max_seconds]
# The 900s default is ample for the ~98MB frontend tarball but nowhere near
# enough for a multi-GB ISO, which also deserves a visible progress bar
# rather than sitting mute for the better part of an hour.
upload_asset() {
local path="$1"
local name="$2"
local max_time="${3:-900}"
if printf '%s\n' "$asset_names" | grep -Fxq "$name"; then
echo "Asset $name already exists; leaving it in place."
return
fi
echo "Uploading $name..."
curl --fail --show-error --silent --http1.1 --connect-timeout 20 --max-time 900 \
local noise=(--silent)
if [ "$max_time" -gt 900 ]; then noise=(--progress-bar); fi
echo "Uploading $name ($(du -h "$path" | cut -f1))..."
curl --fail --show-error "${noise[@]}" --http1.1 --connect-timeout 20 --max-time "$max_time" \
-u "$auth" \
-F "attachment=@$path" \
"$api/releases/$release_id/assets?name=$name" >/dev/null
@@ -131,3 +138,91 @@ echo "Assets verified. Pushing main to $REMOTE (this makes v${VERSION} live)..."
git -C "$PROJECT_ROOT" push "$REMOTE" main
echo "Release v${VERSION} published and verified on $REMOTE."
# ── ISO publication (optional) ───────────────────────────────────────
# Deliberately AFTER main is pushed. The ISO is not referenced by
# releases/manifest.json, so no node's OTA path depends on it — running it
# last means a slow or failed multi-GB upload can never delay, or strand,
# an OTA release that has already been verified.
#
# Skipped cleanly when this version has no ISO yet: create-release.sh runs
# long before scripts/build-iso-release.sh, which needs the very tag this
# script pushes. Re-run this script after building the ISO to attach it.
# SKIP_ISO=1 bypasses the stage entirely.
if [ "${SKIP_ISO:-0}" = "1" ]; then
echo "SKIP_ISO=1 — not publishing an ISO."
exit 0
fi
ISO=$(ls -t "$PROJECT_ROOT"/image-recipe/results/archipelago-installer-"$VERSION"*-x86_64_RC*.iso 2>/dev/null | head -1 || true)
if [ -z "$ISO" ]; then
echo
echo "No ISO built for v${VERSION} — OTA published without one."
echo " Build it: bash scripts/build-iso-release.sh"
echo " Sign it: bash scripts/sign-iso-checksums.sh <iso>"
echo " Attach it: bash scripts/publish-release-assets.sh $VERSION $REMOTE"
exit 0
fi
echo
echo "Publishing ISO: $(basename "$ISO")"
ISO_SHA_FILE="$ISO.sha256"
ISO_SIG_FILE="$ISO.sha256.json"
[ -f "$ISO_SHA_FILE" ] || fail "missing $(basename "$ISO_SHA_FILE") — re-run scripts/build-iso-release.sh"
[ -f "$ISO_SIG_FILE" ] || fail "the ISO checksum is unsigned. Run: bash scripts/sign-iso-checksums.sh $ISO"
# Same supply-chain rule as the OTA manifest: anything published must be
# signed by the pinned release root, and the crypto must actually verify —
# a present-but-bogus signature is the failure mode worth catching.
grep -q '"signature":' "$ISO_SIG_FILE" \
&& grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$ISO_SIG_FILE" \
|| fail "$(basename "$ISO_SIG_FILE") is not signed by the release root — run: bash scripts/sign-iso-checksums.sh $ISO"
if [ -x "$PROJECT_ROOT/core/target/release/archipelago" ]; then
"$PROJECT_ROOT/core/target/release/archipelago" ceremony verify "$ISO_SIG_FILE" \
|| fail "the ISO checksum signature failed cryptographic verification"
fi
# Never upload an image that no longer matches its own checksum. A truncated
# or half-copied ISO is exactly what a signed checksum exists to expose, and
# catching it here is far cheaper than on someone's flashed USB stick.
echo "Checking the ISO against its recorded sha256 (reads the whole image)..."
(cd "$(dirname "$ISO")" && sha256sum --check --status "$(basename "$ISO_SHA_FILE")") \
|| fail "$(basename "$ISO") does not match its .sha256 — rebuild it; do not publish this image"
ISO_NAME=$(basename "$ISO")
# 4h ceiling: a multi-GB image over a domestic uplink is not a 15-minute job.
upload_asset "$ISO" "$ISO_NAME" 14400
upload_asset "$ISO_SHA_FILE" "$ISO_NAME.sha256"
upload_asset "$ISO_SIG_FILE" "$ISO_NAME.sha256.json"
# Verify what actually landed. Re-downloading a multi-GB ISO would cost far
# more than it proves — the signed .sha256.json already lets anyone verify
# the bytes independently — so confirm each asset exists and that Gitea's
# stored size matches the local file exactly.
echo "Verifying uploaded ISO assets..."
assets_json=$(curl -fsS -u "$auth" "$api/releases/$release_id/assets")
python3 - "$assets_json" \
"$ISO_NAME" "$(stat -c%s "$ISO")" \
"$ISO_NAME.sha256" "$(stat -c%s "$ISO_SHA_FILE")" \
"$ISO_NAME.sha256.json" "$(stat -c%s "$ISO_SIG_FILE")" <<'PY' \
|| fail "ISO asset verification failed — the release is missing or has a truncated ISO"
import json
import sys
assets = {a["name"]: a for a in json.loads(sys.argv[1])}
args = sys.argv[2:]
bad = []
for name, size in zip(args[0::2], args[1::2]):
asset = assets.get(name)
if asset is None:
bad.append(f"{name}: missing from the release")
elif int(asset["size"]) != int(size):
bad.append(f"{name}: uploaded {asset['size']} bytes, local file is {size}")
else:
print(f" OK {name} ({asset['size']} bytes)")
for b in bad:
print(" FAIL " + b, file=sys.stderr)
sys.exit(1 if bad else 0)
PY
echo "ISO for v${VERSION} published and verified on $REMOTE."