#!/bin/bash # 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 [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="${1:-$REPO_ROOT/releases/manifest.json}" [[ "$MANIFEST" = /* ]] || MANIFEST="$REPO_ROOT/$MANIFEST" if [ ! -f "$MANIFEST" ]; then echo "❌ manifest missing: $MANIFEST" exit 1 fi fail() { echo "❌ $*"; exit 1; } 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=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$MANIFEST") fi fi if [ "$MANIFEST_VERSION" != "$CARGO_VERSION" ]; then fail "manifest version ($MANIFEST_VERSION) ≠ Cargo.toml ($CARGO_VERSION)" fi ok "version matches: $MANIFEST_VERSION" # Release notes mandatory — ships stuff nobody can read otherwise. Require # curated user/operator-facing notes and reject raw `git log --oneline` output. NOTES_CHECK=$(python3 - "$MANIFEST" <<'PY' import json import re import sys manifest = sys.argv[1] notes = json.load(open(manifest)).get("changelog", []) if len(notes) < 3: print(f"FAIL: changelog has {len(notes)} lines; need at least 3 curated release-note bullets") sys.exit(0) bad = [] for note in notes: text = str(note).strip() if not text: bad.append("empty release-note entry") if len(text) < 40: bad.append(f"too short: {text!r}") if re.match(r"^[0-9a-f]{7,40}\s+", text): bad.append(f"raw commit hash entry: {text!r}") if re.match(r"^(feat|fix|chore|docs|test|refactor|build|ci|perf)(\([^)]+\))?:\s", text): bad.append(f"raw conventional-commit entry: {text!r}") if bad: print("FAIL: release notes must be curated user/operator-facing bullets, not raw git log lines:\n" + "\n".join(bad)) else: print(f"OK: changelog has {len(notes)} curated lines") PY ) case "$NOTES_CHECK" in OK:*) ok "${NOTES_CHECK#OK: }" ;; FAIL:*) fail "${NOTES_CHECK#FAIL: }" ;; *) fail "unexpected release-note validation output: $NOTES_CHECK" ;; esac # Each component: the artifact on disk under releases/v/ must match # the declared sha256 and size_bytes. VERSION_DIR="$REPO_ROOT/releases/v${MANIFEST_VERSION}" if [ ! -d "$VERSION_DIR" ]; then fail "releases/v${MANIFEST_VERSION}/ missing — artifacts not staged" fi COMPONENT_COUNT=$(python3 -c "import json; print(len(json.load(open('$MANIFEST'))['components']))") python3 - "$MANIFEST" "$REPO_ROOT/scripts" <<'PY' import json, re, sys sys.path.insert(0, sys.argv[2]) from release_payload import component_asset_name, validate_release_components m = json.load(open(sys.argv[1])) v = m['version'] assert re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.]+)?', v), 'invalid version' assert re.fullmatch(r'[0-9a-f]{40}', m.get('source_commit', '')), 'missing source commit' validate_release_components(m) for c in m['components']: assert c['new_version'] == v, 'component version mismatch' assert re.fullmatch(r'[0-9a-f]{64}', c['sha256']), 'invalid hash' assert type(c['size_bytes']) is int and c['size_bytes'] > 0, 'invalid size' component_asset_name(c, v) PY for i in $(seq 0 $((COMPONENT_COUNT - 1))); do NAME=$(python3 -c "import json; print(json.load(open('$MANIFEST'))['components'][$i]['name'])") DECLARED_SHA=$(python3 -c "import json; print(json.load(open('$MANIFEST'))['components'][$i]['sha256'])") DECLARED_SIZE=$(python3 -c "import json; print(json.load(open('$MANIFEST'))['components'][$i]['size_bytes'])") # Shared app filenames stay exact. A target OS plan may use only its # signed content-addressed filename while retaining the logical OS name. ASSET_NAME=$(python3 - "$MANIFEST" "$i" "$REPO_ROOT/scripts" <<'PY' import json, sys sys.path.insert(0, sys.argv[3]) from release_payload import component_asset_name m = json.load(open(sys.argv[1])) print(component_asset_name(m['components'][int(sys.argv[2])], m['version'])) PY ) FILE="$VERSION_DIR/$ASSET_NAME" if [ "$NAME" = "archipelago" ]; then FILE="$VERSION_DIR/archipelago" fi if [ ! -f "$FILE" ]; then fail "component '$NAME' file missing at $FILE" fi ACTUAL_SHA=$(sha256sum "$FILE" | awk '{print $1}') ACTUAL_SIZE=$(stat -c%s "$FILE") if [ "$ACTUAL_SHA" != "$DECLARED_SHA" ]; then fail "component '$NAME' sha256 mismatch (declared=$DECLARED_SHA actual=$ACTUAL_SHA)" fi if [ "$ACTUAL_SIZE" != "$DECLARED_SIZE" ]; then fail "component '$NAME' size mismatch (declared=$DECLARED_SIZE actual=$ACTUAL_SIZE)" fi if [[ "$NAME" == *frontend*.tar.gz ]]; then SOURCE_COMMIT=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["source_commit"])' "$MANIFEST") python3 "$REPO_ROOT/scripts/release_payload.py" archive "$FILE" --version "$MANIFEST_VERSION" --commit "$SOURCE_COMMIT" python3 "$REPO_ROOT/scripts/check-ota-runtime.py" "$FILE" \ || fail "frontend runtime payload is stale or incomplete" fi if [ "$NAME" = archipelago ]; then python3 "$REPO_ROOT/scripts/release_payload.py" native "$FILE" fi if [ "$NAME" = os-update-plan.json ]; then python3 - "$REPO_ROOT/scripts" "$FILE" <<'PY' import json, sys sys.path.insert(0, sys.argv[1]) from os_update_plan import validate validate(json.load(open(sys.argv[2]))) PY fi ok "component '$NAME': sha256 + size match on-disk artifact" done echo ok "$MANIFEST local bytes pass — signature, mirrors, uploaded assets and acceptance remain separate gates"