diff --git a/scripts/check-release-assets.sh b/scripts/check-release-assets.sh new file mode 100755 index 00000000..764ff369 --- /dev/null +++ b/scripts/check-release-assets.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# check-release-assets.sh — prove a release's artifacts are actually fetchable +# BEFORE its manifest goes live on main. +# +# The manifest is the trigger: nodes read releases/manifest.json from branch +# main, and the moment it names a new version they try to download it. So the +# assets must resolve before the manifest lands, not after. On 2026-08-07 the +# order was reversed — the v1.7.126-alpha manifest went live while its binary +# 500'd and its frontend tarball had never uploaded — and every polling node +# would have advertised an update it could not fetch. +# +# For each component in the manifest this checks: +# 1. the download URL returns HTTP 200 +# 2. the downloaded bytes match the manifest's sha256 and size +# +# It downloads each asset in full, because a HEAD 200 is not proof the body is +# intact — the corrupt binary that day passed HEAD-shaped checks and still +# served a broken stream. Slower, but this is the last gate before publish. +# +# Usage: +# scripts/check-release-assets.sh # check releases/manifest.json +# scripts/check-release-assets.sh path/to/manifest.json +# +# Exit 0 = every asset is downloadable and matches. Non-zero = do NOT publish. +set -euo pipefail + +MANIFEST="${1:-releases/manifest.json}" +if [[ ! -f "$MANIFEST" ]]; then + echo "ERROR: manifest not found: $MANIFEST" >&2 + exit 2 +fi + +command -v python3 >/dev/null 2>&1 || { echo "ERROR: python3 required" >&2; exit 2; } +command -v sha256sum >/dev/null 2>&1 || { echo "ERROR: sha256sum required" >&2; exit 2; } + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# Emit "urlsha256sizename" per component, tolerating the field +# name variations the manifest has used (download_url/url, size_bytes/size). +rows="$(python3 - "$MANIFEST" <<'PY' +import json, sys +d = json.load(open(sys.argv[1])) +comps = d.get("components") or [] +if not comps: + sys.exit("manifest has no components") +for c in comps: + url = c.get("download_url") or c.get("url") or "" + sha = c.get("sha256") or "" + size = c.get("size_bytes") or c.get("size") or "" + name = c.get("name") or "(unnamed)" + if not url or not sha: + sys.exit(f"component {name!r} missing url or sha256") + print(f"{url}\t{sha}\t{size}\t{name}") +PY +)" + +version="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("version","?"))' "$MANIFEST")" +echo "Checking release assets for v${version} ($MANIFEST)" +echo "" + +fail=0 +n=0 +while IFS=$'\t' read -r url sha size name; do + [ -z "$url" ] && continue + n=$((n + 1)) + out="$TMP/asset.$n" + echo " [$name]" + echo " $url" + + code="$(curl -sL -o "$out" -w '%{http_code}' "$url" || echo "000")" + if [ "$code" != "200" ]; then + echo " FAIL: HTTP $code (asset not served)" + fail=1 + continue + fi + + got_size="$(stat -c%s "$out")" + if [ -n "$size" ] && [ "$size" != "$got_size" ]; then + echo " FAIL: size $got_size, manifest says $size" + fail=1 + continue + fi + + got_sha="$(sha256sum "$out" | awk '{print $1}')" + if [ "$got_sha" != "$sha" ]; then + echo " FAIL: sha256 mismatch" + echo " served: $got_sha" + echo " manifest: $sha" + fail=1 + continue + fi + + echo " OK: HTTP 200, ${got_size} bytes, sha256 matches" +done <<< "$rows" + +echo "" +if [ "$fail" -ne 0 ]; then + echo "REFUSING: one or more assets are not fetchable or do not match the manifest." + echo "Do NOT publish the manifest — nodes would advertise an update they cannot" + echo "apply. Upload/repair the assets, re-run this, and only then flip the" + echo "manifest live on main." + exit 1 +fi +echo "OK: all $n asset(s) for v${version} download and match the manifest." diff --git a/scripts/publish-release-assets.sh b/scripts/publish-release-assets.sh index 14de7993..da645cc1 100755 --- a/scripts/publish-release-assets.sh +++ b/scripts/publish-release-assets.sh @@ -62,8 +62,19 @@ repo_path=${repo_path%.git} api="$scheme://$host/api/v1/repos/$repo_path" release_url="$api/releases/tags/v${VERSION}" -echo "Pushing main and v${VERSION} to $REMOTE..." -git -C "$PROJECT_ROOT" push "$REMOTE" main "refs/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 @@ -107,24 +118,16 @@ upload_asset() { upload_asset "$BACKEND" "archipelago" upload_asset "$FRONTEND" "archipelago-frontend-${VERSION}.tar.gz" -echo "Verifying public download URLs from manifest (size + sha256)..." -python3 - "$PROJECT_ROOT/releases/manifest.json" <<'PY' | while read -r url size sha; do -import json -import sys +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." -manifest = json.load(open(sys.argv[1])) -for component in manifest["components"]: - print(component["download_url"], component["size_bytes"], component["sha256"]) -PY - # Full GET, not HEAD: a size-correct/content-wrong mirror asset must fail - # the publish gate, so compare the actual bytes against the manifest sha256. - tmp=$(mktemp) - curl -fsSL --max-time 900 -o "$tmp" "$url" || { rm -f "$tmp"; fail "download URL failed: $url"; } - actual_size=$(stat -c %s "$tmp") - actual_sha=$(sha256sum "$tmp" | awk '{print $1}') - rm -f "$tmp" - [ "$actual_size" = "$size" ] || fail "download size mismatch for $url (expected $size, got $actual_size)" - [ "$actual_sha" = "$sha" ] || fail "download sha256 mismatch for $url (expected $sha, got $actual_sha)" -done +# 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."