Files
archy/scripts/publish-release-assets.sh
T

229 lines
10 KiB
Bash
Raw Normal View History

2026-08-12 10:55:50 +00:00
#!/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.
2026-08-12 10:55:50 +00:00
upload_asset() {
local path="$1"
local name="$2"
local max_time="${3:-900}"
2026-08-12 10:55:50 +00:00
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" \
2026-08-12 10:55:50 +00:00
-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."