fix(release): publish assets before exposing manifest

This commit is contained in:
archipelago
2026-08-31 14:45:29 -04:00
parent 9f1a289d1a
commit e3275353b9
5 changed files with 116 additions and 74 deletions
+18 -5
View File
@@ -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}"
+4 -4
View File
@@ -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)"
+39 -49
View File
@@ -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 ""
+46 -10
View File
@@ -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."
+9 -6
View File
@@ -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."