DO NOT MERGE INTO A RELEASE SIGNED WITH THE NEW KEY. See below.
The previous release root (z6Mkkid…q7ur, pinned 2026-07-02) was exposed
in a chat transcript and is treated as compromised. It signs both OTA
manifests and the app catalog, so anyone holding it could sign updates
the fleet would install.
Pins the new key in trust::anchor and moves EXPECTED_DID in all three
signing/publishing scripts.
ORDERING IS CRITICAL — nodes pin the OLD key:
* The release CARRYING this commit must be signed with the OLD key.
That is the only signature a node running the previous binary will
accept, and it is what installs the binary pinning the new key.
* Only the release AFTER that may be signed with the new key.
* Signing this release with the new key makes every node reject it,
ending OTA fleet-wide and requiring hands-on recovery per node.
sign-catalog.sh moves in the same commit, so the app catalog must also be
re-signed with the new key once this ships, or nodes accept the binary
and reject the catalog.
Key verified before pinning: the hex and the did:key are the same
keypair, checked with a base58 decoder round-tripped against the previous
known-good pair. An earlier candidate hex (cb830e13…) was rejected
because it decoded to a different DID than the one supplied — pinning it
would have made every node reject every future update.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
122 lines
4.6 KiB
Bash
Executable File
122 lines
4.6 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.
|
|
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")
|
|
case "$remote_url" in
|
|
http://*@*) ;;
|
|
*) fail "$REMOTE must be an authenticated http:// Gitea remote URL for API uploads" ;;
|
|
esac
|
|
|
|
auth=${remote_url#http://}
|
|
auth=${auth%@*}
|
|
host_path=${remote_url#http://$auth@}
|
|
host=${host_path%%/*}
|
|
repo_path=${host_path#*/}
|
|
repo_path=${repo_path%.git}
|
|
api="http://$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}"
|
|
|
|
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() {
|
|
local path="$1"
|
|
local name="$2"
|
|
if printf '%s\n' "$asset_names" | grep -Fxq "$name"; then
|
|
echo "Asset $name already exists; leaving it in place."
|
|
return
|
|
fi
|
|
echo "Uploading $name..."
|
|
curl --fail --show-error --silent --http1.1 --connect-timeout 20 --max-time 900 \
|
|
-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 from manifest (size + sha256)..."
|
|
python3 - "$PROJECT_ROOT/releases/manifest.json" <<'PY' | while read -r url size sha; do
|
|
import json
|
|
import sys
|
|
|
|
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
|
|
|
|
echo "Release v${VERSION} published and verified on $REMOTE."
|