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>
229 lines
10 KiB
Bash
Executable File
229 lines
10 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Publish an Archipelago OTA release to a Gitea remote and verify downloads.
|
|
|
|
set -euo pipefail
|
|
|
|
VERSION="${1:-}"
|
|
REMOTE="${2:-gitea-vps2}"
|
|
|
|
if [ -z "$VERSION" ]; then
|
|
echo "Usage: $0 VERSION [remote]"
|
|
exit 1
|
|
fi
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}"
|
|
BACKEND="$VERSION_DIR/archipelago"
|
|
FRONTEND="$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz"
|
|
|
|
fail() { echo "Error: $*" >&2; exit 1; }
|
|
|
|
[ -f "$PROJECT_ROOT/releases/manifest.json" ] || fail "releases/manifest.json missing"
|
|
[ -f "$BACKEND" ] || fail "backend artifact missing: $BACKEND"
|
|
[ -f "$FRONTEND" ] || fail "frontend artifact missing: $FRONTEND"
|
|
|
|
"$SCRIPT_DIR/check-release-manifest.sh"
|
|
|
|
# §A supply-chain gate: never publish an unsigned OTA manifest. Fleet nodes
|
|
# with the pinned release-root anchor refuse to auto-apply unsigned manifests,
|
|
# and enforcement will tighten to hard-reject — an unsigned publish would
|
|
# strand them. Grep proves presence; ceremony verify proves the crypto.
|
|
# Release root ROTATED 2026-08-05; see create-release.sh. New root from
|
|
# v1.7.123 onward.
|
|
EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT"
|
|
grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \
|
|
&& grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json" \
|
|
|| fail "releases/manifest.json is not signed by the release root — run: bash scripts/sign-manifest.sh"
|
|
if [ -x "$PROJECT_ROOT/core/target/release/archipelago" ]; then
|
|
"$PROJECT_ROOT/core/target/release/archipelago" ceremony verify "$PROJECT_ROOT/releases/manifest.json" \
|
|
|| fail "manifest signature failed cryptographic verification"
|
|
fi
|
|
|
|
remote_url=$(git -C "$PROJECT_ROOT" remote get-url "$REMOTE")
|
|
# https is accepted as well as http. Requiring http:// meant the only remote
|
|
# whose credential actually works for git push (the https one) was rejected,
|
|
# while the http remote it forced you to use had a dead token — so publishing
|
|
# failed on auth after the manifest had already passed every check
|
|
# (v1.7.121-alpha, 2026-08-04). The scheme is carried through to the API URL
|
|
# rather than assumed.
|
|
case "$remote_url" in
|
|
http://*@*|https://*@*) ;;
|
|
*) fail "$REMOTE must be an authenticated http(s):// Gitea remote URL for API uploads" ;;
|
|
esac
|
|
|
|
scheme=${remote_url%%://*}
|
|
rest=${remote_url#*://}
|
|
auth=${rest%%@*}
|
|
host_path=${rest#*@}
|
|
host=${host_path%%/*}
|
|
repo_path=${host_path#*/}
|
|
repo_path=${repo_path%.git}
|
|
api="$scheme://$host/api/v1/repos/$repo_path"
|
|
release_url="$api/releases/tags/v${VERSION}"
|
|
|
|
# ORDER MATTERS. The manifest is the trigger — nodes read releases/manifest.json
|
|
# from branch main and try to download the named version the moment it appears.
|
|
# So main (which carries the live manifest) must be pushed LAST, only after the
|
|
# assets are uploaded and their bytes verified against the manifest. The tag is
|
|
# pushed first because the Gitea release and its asset download URLs hang off it,
|
|
# but the tag alone changes nothing for nodes.
|
|
#
|
|
# This used to push main and the tag together, up front, then upload assets. That
|
|
# left the manifest live for the entire upload+verify window — and on 2026-08-07
|
|
# an upload failed inside that window, so every polling node briefly advertised a
|
|
# v1.7.126-alpha update whose binary 500'd and whose tarball did not exist.
|
|
echo "Pushing tag v${VERSION} to $REMOTE (not main yet)..."
|
|
git -C "$PROJECT_ROOT" push "$REMOTE" "refs/tags/v${VERSION}"
|
|
|
|
release_json=$(curl -fsS -u "$auth" "$release_url" || true)
|
|
if [ -z "$release_json" ]; then
|
|
echo "Creating Gitea release v${VERSION}..."
|
|
release_body=$(python3 - "$VERSION" <<'PY'
|
|
import json
|
|
import sys
|
|
|
|
version = sys.argv[1]
|
|
print(json.dumps({
|
|
"tag_name": f"v{version}",
|
|
"target_commitish": "main",
|
|
"name": f"v{version}",
|
|
"body": f"Archipelago v{version} release artifacts for OTA updates.",
|
|
"draft": False,
|
|
"prerelease": True,
|
|
}))
|
|
PY
|
|
)
|
|
release_json=$(curl -fsS -u "$auth" -H 'Content-Type: application/json' -d "$release_body" "$api/releases")
|
|
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
|
|
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
|
|
asset_names=$(printf '%s\n%s\n' "$asset_names" "$name")
|
|
}
|
|
|
|
upload_asset "$BACKEND" "archipelago"
|
|
upload_asset "$FRONTEND" "archipelago-frontend-${VERSION}.tar.gz"
|
|
|
|
echo "Verifying public download URLs (full GET + size + sha256)..."
|
|
# Delegated to check-release-assets.sh so the same verifier is used here and by
|
|
# hand during recovery. It fails hard on the first bad asset — the previous
|
|
# inline `while read` ran in a pipe subshell, where a `fail` (exit) killed only
|
|
# the subshell and let this script march on to "published and verified".
|
|
"$PROJECT_ROOT/scripts/check-release-assets.sh" "$PROJECT_ROOT/releases/manifest.json" \
|
|
|| fail "asset verification failed — NOT pushing main. The manifest stays off the branch nodes read, so no node sees a version it cannot fetch. Repair the assets and re-run."
|
|
|
|
# Assets are proven fetchable — only now does the manifest become live.
|
|
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."
|