fix(release): close two publish-path verification gaps (§B hardening)

- create-release.sh: assert the freshly built frontend dist actually embeds
  the bumped version before packaging — the ui-dist-version guard in
  tests/release/run.sh is behind --with-build, which the release path never
  passes, so a silently no-opped npm build could ship a stale dist with a
  valid sha256. Verified against the real dist (passes on current version,
  trips on a missing one).
- publish-release-assets.sh: verify published assets by downloading and
  comparing sha256 against the manifest, not just Content-Length — a
  size-correct/content-wrong mirror asset now fails the publish gate.
  Verified live against the v1.7.99-alpha assets on the vps2 mirror.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-08 19:04:14 -04:00
parent 1316b0c0d4
commit e77ccff0e1
2 changed files with 21 additions and 6 deletions

View File

@ -158,6 +158,15 @@ cd "$PROJECT_ROOT/neode-ui"
npm run build 2>&1 | tail -3 npm run build 2>&1 | tail -3
cd "$PROJECT_ROOT" cd "$PROJECT_ROOT"
# npm run build can silently no-op (vue-tsc EACCES burned us before) — a stale
# dist would ship with a perfectly valid sha256. Require the freshly built
# bundle to embed the version we just bumped to before it gets packaged.
if ! grep -rqo "${VERSION}" "$PROJECT_ROOT"/web/dist/neode-ui/assets/*.js; then
echo "Error: web/dist/neode-ui does not contain v${VERSION} — the frontend" >&2
echo " build no-opped or its output is stale. Aborting release." >&2
exit 1
fi
echo "[5/8] Validating curated changelog..." echo "[5/8] Validating curated changelog..."
CHANGELOG_FILE="$PROJECT_ROOT/CHANGELOG.md" CHANGELOG_FILE="$PROJECT_ROOT/CHANGELOG.md"

View File

@ -98,18 +98,24 @@ upload_asset() {
upload_asset "$BACKEND" "archipelago" upload_asset "$BACKEND" "archipelago"
upload_asset "$FRONTEND" "archipelago-frontend-${VERSION}.tar.gz" upload_asset "$FRONTEND" "archipelago-frontend-${VERSION}.tar.gz"
echo "Verifying public download URLs from manifest..." echo "Verifying public download URLs from manifest (size + sha256)..."
python3 - "$PROJECT_ROOT/releases/manifest.json" <<'PY' | while read -r url size; do python3 - "$PROJECT_ROOT/releases/manifest.json" <<'PY' | while read -r url size sha; do
import json import json
import sys import sys
manifest = json.load(open(sys.argv[1])) manifest = json.load(open(sys.argv[1]))
for component in manifest["components"]: for component in manifest["components"]:
print(component["download_url"], component["size_bytes"]) print(component["download_url"], component["size_bytes"], component["sha256"])
PY PY
headers=$(curl -fsSI -L --max-time 60 "$url") || fail "download URL failed: $url" # Full GET, not HEAD: a size-correct/content-wrong mirror asset must fail
actual_size=$(printf '%s\n' "$headers" | awk 'tolower($1)=="content-length:" { size=$2 } END { gsub("\r", "", size); print size }') # the publish gate, so compare the actual bytes against the manifest sha256.
[ "$actual_size" = "$size" ] || fail "download size mismatch for $url (expected $size, got ${actual_size:-missing})" 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 done
echo "Release v${VERSION} published and verified on $REMOTE." echo "Release v${VERSION} published and verified on $REMOTE."