From e3275353b9178b40ce68104691998fe674ccc323 Mon Sep 17 00:00:00 2001 From: archipelago Date: Mon, 31 Aug 2026 14:45:29 -0400 Subject: [PATCH] fix(release): publish assets before exposing manifest --- scripts/check-release-manifest.sh | 23 ++++++-- scripts/create-release-manifest.sh | 8 +-- scripts/create-release.sh | 88 +++++++++++++----------------- scripts/publish-release-assets.sh | 56 +++++++++++++++---- scripts/sign-manifest.sh | 15 +++-- 5 files changed, 116 insertions(+), 74 deletions(-) diff --git a/scripts/check-release-manifest.sh b/scripts/check-release-manifest.sh index f5f657f1..09f86a50 100755 --- a/scripts/check-release-manifest.sh +++ b/scripts/check-release-manifest.sh @@ -1,21 +1,22 @@ #!/bin/bash -# Validate releases/manifest.json: +# Validate the live or a pending release manifest: # - version matches core/archipelago/Cargo.toml # - changelog contains curated release notes, not raw git log output # - every component's download_url exists on disk and matches sha256/size # # Run on every push from CI, and also locally before publishing a release: -# scripts/check-release-manifest.sh +# scripts/check-release-manifest.sh [path/to/manifest.json] # # Exits non-zero on any mismatch so the release process fails loud. set -eo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -MANIFEST="$REPO_ROOT/releases/manifest.json" +MANIFEST="${1:-$REPO_ROOT/releases/manifest.json}" +[[ "$MANIFEST" = /* ]] || MANIFEST="$REPO_ROOT/$MANIFEST" if [ ! -f "$MANIFEST" ]; then - echo "❌ releases/manifest.json missing" + echo "❌ manifest missing: $MANIFEST" exit 1 fi @@ -25,6 +26,18 @@ ok() { echo "✅ $*"; } MANIFEST_VERSION=$(python3 -c "import json; print(json.load(open('$MANIFEST'))['version'])") CARGO_VERSION=$(grep '^version' "$REPO_ROOT/core/archipelago/Cargo.toml" | head -1 | sed -E 's/.*"([^"]+)".*/\1/') +# A prepared release deliberately leaves the live manifest on the previous +# version. Ordinary pushes are therefore harmless: only the publisher promotes +# the pending manifest after its assets have been uploaded and downloaded back. +if [ "$MANIFEST_VERSION" != "$CARGO_VERSION" ] && [ "$MANIFEST" = "$REPO_ROOT/releases/manifest.json" ]; then + PENDING="$REPO_ROOT/releases/pending/v${CARGO_VERSION}/manifest.json" + if [ -f "$PENDING" ]; then + ok "live manifest remains v${MANIFEST_VERSION} while v${CARGO_VERSION} is pending" + MANIFEST="$PENDING" + MANIFEST_VERSION="$CARGO_VERSION" + fi +fi + if [ "$MANIFEST_VERSION" != "$CARGO_VERSION" ]; then fail "manifest version ($MANIFEST_VERSION) ≠ Cargo.toml ($CARGO_VERSION)" fi @@ -105,4 +118,4 @@ for i in $(seq 0 $((COMPONENT_COUNT - 1))); do done echo -ok "releases/manifest.json passes all checks — safe to publish v${MANIFEST_VERSION}" +ok "$MANIFEST passes all checks — safe to publish v${MANIFEST_VERSION}" diff --git a/scripts/create-release-manifest.sh b/scripts/create-release-manifest.sh index fa4eff77..6ff8cf82 100755 --- a/scripts/create-release-manifest.sh +++ b/scripts/create-release-manifest.sh @@ -298,7 +298,7 @@ echo "" cat "$OUTPUT_FILE" echo "" echo "Next steps:" -echo " 1. Review the manifest above" -echo " 2. Upload artifacts to Gitea release v$VERSION" -echo " 3. Commit manifest.json to releases/manifest.json on main" -echo " 4. Tag the release: git tag v$VERSION && git push --tags" +echo " 1. Review and sign the manifest above" +echo " 2. Keep it under releases/pending/v$VERSION/ — do NOT replace the live manifest" +echo " 3. Run scripts/publish-release-assets.sh $VERSION gitea-vps2" +echo " (it uploads + verifies assets before atomically promoting the manifest)" diff --git a/scripts/create-release.sh b/scripts/create-release.sh index fede3cc2..b4b8c4dd 100755 --- a/scripts/create-release.sh +++ b/scripts/create-release.sh @@ -2,7 +2,8 @@ # create-release.sh — Full release automation for Archipelago # # Bumps version in Cargo.toml and package.json, generates changelog from git log, -# creates release manifest, and creates git tag. +# creates a pending release manifest, and creates git tag. The live manifest is +# promoted only by publish-release-assets.sh after the assets are verified. # # Usage: # ./scripts/create-release.sh 1.0.0 # Release v1.0.0 @@ -30,9 +31,9 @@ for arg in "$@"; do echo " 2. Bump version in Cargo.toml and package.json" echo " 3. Build backend" echo " 4. Build frontend" - echo " 5. Generate changelog from git log" - echo " 6. Create release manifest" - echo " 7. Commit version bump" + echo " 5. Validate the curated changelog" + echo " 6. Create pending release manifest" + echo " 7. Commit release preparation" echo " 8. Create git tag v{VERSION}" echo "" echo "Options:" @@ -121,14 +122,13 @@ if $DRY_RUN; then echo " 2. Update neode-ui/package.json version to $VERSION" echo " 3. Build backend (cargo build --release -p archipelago)" echo " 4. Build frontend (npm run build)" - echo " 5. Generate changelog from git log since v${CURRENT_CARGO_VERSION}" - echo " 6. Create release manifest" - echo " 7. Commit: 'chore: release v${VERSION}'" + echo " 5. Validate the curated changelog" + echo " 6. Create pending release manifest (the live manifest stays unchanged)" + echo " 7. Commit: 'chore: prepare release v${VERSION}'" echo " 8. Tag: v${VERSION}" echo "" - echo "After this script, you would:" - echo " - Push: git push && git push --tags" - echo " - Build ISOs on server: ssh archipelago@192.0.2.10" + echo "After this script, publish only with:" + echo " scripts/publish-release-assets.sh ${VERSION} gitea-vps2" exit 0 fi @@ -214,9 +214,13 @@ if [ ! -f "$CHANGELOG_FILE" ] || ! grep -q "^## v${VERSION} (" "$CHANGELOG_FILE" exit 1 fi -echo "[6/8] Creating release manifest..." -mkdir -p "$PROJECT_ROOT/releases" -"$SCRIPT_DIR/create-release-manifest.sh" --version "$VERSION" --date "$RELEASE_DATE" --output "$PROJECT_ROOT/releases/manifest.json" 2>&1 | grep -v "^$" +echo "[6/8] Creating pending release manifest..." +# Never write the fleet-visible path here. A normal `git push main` must not be +# capable of advertising assets which have not been uploaded yet. +PENDING_DIR="$PROJECT_ROOT/releases/pending/v${VERSION}" +PENDING_MANIFEST="$PENDING_DIR/manifest.json" +mkdir -p "$PENDING_DIR" +"$SCRIPT_DIR/create-release-manifest.sh" --version "$VERSION" --date "$RELEASE_DATE" --output "$PENDING_MANIFEST" 2>&1 | grep -v "^$" # §A supply-chain: the OTA manifest must carry the release-root signature. # Nodes refuse to AUTO-apply unsigned manifests, and publish-release-assets.sh @@ -239,60 +243,45 @@ if [ -n "${RELEASE_MASTER_MNEMONIC:-}" ] || [ -t 0 ]; then echo " Enter by itself will NOT submit; pasting twice concatenates" echo " the phrases and fails on word count." echo "════════════════════════════════════════════════════════════════" - "$SIGNER" ceremony sign "$PROJECT_ROOT/releases/manifest.json" - "$SIGNER" ceremony verify "$PROJECT_ROOT/releases/manifest.json" + "$SIGNER" ceremony sign "$PENDING_MANIFEST" + "$SIGNER" ceremony verify "$PENDING_MANIFEST" else - echo "⚠ WARNING: no TTY and RELEASE_MASTER_MNEMONIC unset — manifest left UNSIGNED." - echo " This run will ABORT before committing (step 7 refuses an unsigned" - echo " manifest), because nodes read releases/manifest.json from branch main" - echo " and would refuse to auto-apply it." - echo " Sign it, then re-run: bash scripts/sign-manifest.sh" + echo "⚠ WARNING: no TTY and RELEASE_MASTER_MNEMONIC unset — pending manifest left UNSIGNED." + echo " This run will ABORT before committing (step 7 refuses an unsigned manifest)." + echo " Sign it, then re-run: bash scripts/sign-manifest.sh $PENDING_MANIFEST" fi -cp "$PROJECT_ROOT/releases/manifest.json" "$PROJECT_ROOT/release-manifest.json" - echo "[6c/8] Staging release artifacts for validation..." VERSION_DIR="$PROJECT_ROOT/releases/v${VERSION}" FRONTEND_ARCHIVE="/tmp/archipelago-frontend-${VERSION}.tar.gz" mkdir -p "$VERSION_DIR" install -m 0755 "$PROJECT_ROOT/core/target/release/archipelago" "$VERSION_DIR/archipelago" install -m 0644 "$FRONTEND_ARCHIVE" "$VERSION_DIR/archipelago-frontend-${VERSION}.tar.gz" -"$SCRIPT_DIR/check-release-manifest.sh" +"$SCRIPT_DIR/check-release-manifest.sh" "$PENDING_MANIFEST" -# §A supply-chain gate, mirroring publish-release-assets.sh — but EARLIER, -# because publishing is not the first way an unsigned manifest reaches the -# fleet. Nodes fetch releases/manifest.json straight from branch `main` -# (see the verification URLs printed below), so the COMMIT is what exposes -# it, not the publish. publish-release-assets.sh refusing to ship is a -# backstop that arrives one step too late: by then the unsigned manifest is -# already on main and the fleet is already refusing to auto-apply. -# -# This is why every cycle needed a manual catch. The signing block above is -# conditional — no TTY and no RELEASE_MASTER_MNEMONIC means it prints a -# warning and falls through — and the commit then happened anyway. A release -# commit carrying a manifest no node will accept has no valid use, so refuse -# to create one rather than leave a tag that has to be re-cut. +# §A supply-chain gate, mirroring publish-release-assets.sh. The pending path +# prevents an ordinary main push from exposing the release, but an unsigned +# manifest is still unpublishable and must never be tagged as ready. # Release root ROTATED 2026-08-05. v1.7.122-alpha was the last release signed # with the old root (z6Mkkid…q7ur) — it is the release that installed this # pin on every node. From v1.7.123 onward the new root signs, and nodes # running .122+ reject anything signed with the old key. EXPECTED_DID="did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT" -if ! grep -q '"signature":' "$PROJECT_ROOT/releases/manifest.json" \ - || ! grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PROJECT_ROOT/releases/manifest.json"; then +if ! grep -q '"signature":' "$PENDING_MANIFEST" \ + || ! grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$PENDING_MANIFEST"; then echo "" >&2 - echo "Error: releases/manifest.json is NOT signed by the release root." >&2 - echo " Refusing to commit — nodes read this file from branch main and will" >&2 - echo " refuse to auto-apply it, so the release would be dead on arrival." >&2 + echo "Error: the pending manifest is NOT signed by the release root." >&2 + echo " Refusing to commit an unpublishable release." >&2 echo "" >&2 echo " Sign it, then re-run this script:" >&2 - echo " bash scripts/sign-manifest.sh" >&2 + echo " bash scripts/sign-manifest.sh $PENDING_MANIFEST" >&2 echo "" >&2 echo " (Signing needs a TTY for the mnemonic prompt, or RELEASE_MASTER_MNEMONIC set.)" >&2 exit 1 fi -"$SIGNER" ceremony verify "$PROJECT_ROOT/releases/manifest.json" \ +"$SIGNER" ceremony verify "$PENDING_MANIFEST" \ || { echo "Error: manifest signature failed cryptographic verification — refusing to commit" >&2; exit 1; } -echo "[7/8] Committing version bump..." +echo "[7/8] Committing release preparation..." git -C "$PROJECT_ROOT" add \ core/archipelago/Cargo.toml \ core/Cargo.lock \ @@ -300,15 +289,16 @@ git -C "$PROJECT_ROOT" add \ neode-ui/package-lock.json \ neode-ui/public/catalog.json \ CHANGELOG.md \ - releases/manifest.json \ - release-manifest.json \ 2>/dev/null || true +# releases/** is ignored because binaries live in Gitea attachments; force-add +# only this small signed pending manifest. +git -C "$PROJECT_ROOT" add -f "releases/pending/v${VERSION}/manifest.json" # Cargo.lock (rewritten by the release build after the version bump) and # neode-ui/public/catalog.json (regenerated by the frontend build) belong in # THIS commit: leaving them dirty failed build-iso-release.sh's clean-tree # preflight on three consecutive releases (.127-.129, 2026-08-09/10). -git -C "$PROJECT_ROOT" commit -m "chore: release v${VERSION}" +git -C "$PROJECT_ROOT" commit -m "chore: prepare release v${VERSION}" echo "[8/8] Creating git tag..." git -C "$PROJECT_ROOT" tag -a "v${VERSION}" -m "Release v${VERSION}" @@ -319,8 +309,8 @@ echo "" echo "Artifacts:" echo " - Version bumped in Cargo.toml and package.json" echo " - Changelog updated in CHANGELOG.md" -echo " - Release manifest: releases/manifest.json" -echo " - Release manifest copy: release-manifest.json" +echo " - Pending manifest: releases/pending/v${VERSION}/manifest.json" +echo " - Live manifest: unchanged until assets pass publication verification" echo " - Staged artifacts: releases/v${VERSION}/" echo " - Git tag: v${VERSION}" echo "" diff --git a/scripts/publish-release-assets.sh b/scripts/publish-release-assets.sh index b939e253..b7c9633c 100755 --- a/scripts/publish-release-assets.sh +++ b/scripts/publish-release-assets.sh @@ -16,14 +16,26 @@ 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" +PENDING_MANIFEST="$PROJECT_ROOT/releases/pending/v${VERSION}/manifest.json" +LIVE_MANIFEST="$PROJECT_ROOT/releases/manifest.json" +if [ -f "$PENDING_MANIFEST" ]; then + MANIFEST="$PENDING_MANIFEST" + PROMOTE_MANIFEST=1 +else + # Backward compatibility for releases prepared before pending manifests. + MANIFEST="$LIVE_MANIFEST" + PROMOTE_MANIFEST=0 +fi fail() { echo "Error: $*" >&2; exit 1; } -[ -f "$PROJECT_ROOT/releases/manifest.json" ] || fail "releases/manifest.json missing" +[ -f "$MANIFEST" ] || fail "release manifest missing: $MANIFEST" +MANIFEST_VERSION=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$MANIFEST") +[ "$MANIFEST_VERSION" = "$VERSION" ] || fail "requested v$VERSION but $MANIFEST describes v$MANIFEST_VERSION" [ -f "$BACKEND" ] || fail "backend artifact missing: $BACKEND" [ -f "$FRONTEND" ] || fail "frontend artifact missing: $FRONTEND" -"$SCRIPT_DIR/check-release-manifest.sh" +"$SCRIPT_DIR/check-release-manifest.sh" "$MANIFEST" # §A supply-chain gate: never publish an unsigned OTA manifest. Fleet nodes # with the pinned release-root anchor refuse to auto-apply unsigned manifests, @@ -32,11 +44,11 @@ fail() { echo "Error: $*" >&2; exit 1; } # 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" +grep -q '"signature":' "$MANIFEST" \ + && grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$MANIFEST" \ + || fail "$MANIFEST is not signed by the release root — run: bash scripts/sign-manifest.sh $MANIFEST" if [ -x "$PROJECT_ROOT/core/target/release/archipelago" ]; then - "$PROJECT_ROOT/core/target/release/archipelago" ceremony verify "$PROJECT_ROOT/releases/manifest.json" \ + "$PROJECT_ROOT/core/target/release/archipelago" ceremony verify "$MANIFEST" \ || fail "manifest signature failed cryptographic verification" fi @@ -130,12 +142,36 @@ echo "Verifying public download URLs (full GET + size + sha256)..." # 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" \ +"$PROJECT_ROOT/scripts/check-release-assets.sh" "$MANIFEST" \ || 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 +# Assets are proven fetchable — only now may the manifest become live. First +# incorporate concurrent work, then promote in a dedicated commit. Until the +# final push succeeds the remote still serves the previous manifest. +echo "Assets verified. Synchronizing main before manifest promotion..." +git -C "$PROJECT_ROOT" fetch "$REMOTE" main +git -C "$PROJECT_ROOT" merge --no-edit "$REMOTE/main" + +if [ "$PROMOTE_MANIFEST" = "1" ]; then + cp "$MANIFEST" "$LIVE_MANIFEST" + cp "$MANIFEST" "$PROJECT_ROOT/release-manifest.json" + git -C "$PROJECT_ROOT" add releases/manifest.json release-manifest.json + git -C "$PROJECT_ROOT" rm -f -- "releases/pending/v${VERSION}/manifest.json" + git -C "$PROJECT_ROOT" commit -m "chore: publish release v${VERSION}" +fi + +echo "Publishing verified manifest to main (this makes v${VERSION} live)..." +# A concurrent push can race the fetch above. Merge and retry without ever +# force-pushing; the remote remains on its old, working manifest meanwhile. +for attempt in 1 2 3; do + if git -C "$PROJECT_ROOT" push "$REMOTE" HEAD:main; then + break + fi + [ "$attempt" -lt 3 ] || fail "main advanced repeatedly; assets are safe but manifest was not promoted" + echo "main advanced during publication; merging and retrying..." + git -C "$PROJECT_ROOT" fetch "$REMOTE" main + git -C "$PROJECT_ROOT" merge --no-edit "$REMOTE/main" +done echo "Release v${VERSION} published and verified on $REMOTE." diff --git a/scripts/sign-manifest.sh b/scripts/sign-manifest.sh index c223e995..af63acd8 100755 --- a/scripts/sign-manifest.sh +++ b/scripts/sign-manifest.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash # One-step OTA-manifest signer (counterpart to sign-catalog.sh). # -# Run: bash scripts/sign-manifest.sh +# Run: bash scripts/sign-manifest.sh [path/to/manifest.json] # Then: paste your 24-word release master mnemonic, press Enter, then Ctrl-D. # -# Signs releases/manifest.json in place and cryptographically verifies the +# Signs the requested manifest (live by default) and cryptographically verifies the # result against the pinned release-root anchor. The mnemonic is read from the # terminal only (never stored, never in shell history, never passed to Claude). # @@ -18,7 +18,9 @@ set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -MANIFEST="$REPO/releases/manifest.json" +MANIFEST="${1:-$REPO/releases/manifest.json}" +[[ "$MANIFEST" = /* ]] || MANIFEST="$REPO/$MANIFEST" +[ -f "$MANIFEST" ] || { echo "Manifest not found: $MANIFEST" >&2; exit 1; } # Use ONLY a prebuilt signer — never compile here (compiling caused hangs in # the earlier catalog ceremony). Prefer the repo's release build. @@ -41,9 +43,10 @@ echo "════════════════════════ echo if "$BIN" ceremony verify "$MANIFEST"; then - echo "✅ SUCCESS — manifest signed by the pinned release root." - echo " Commit + push releases/manifest.json (and release-manifest.json if present)." - cp "$MANIFEST" "$REPO/release-manifest.json" 2>/dev/null || true + echo "✅ SUCCESS — manifest signed by the pinned release root: $MANIFEST" + if [ "$MANIFEST" = "$REPO/releases/manifest.json" ]; then + cp "$MANIFEST" "$REPO/release-manifest.json" + fi else echo "❌ Signature did NOT verify against the pinned release-root anchor." echo " Do NOT commit. Check the mnemonic and re-run."