#!/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}" TARGET_PLAN="${3:-}" [[ "$TARGET_PLAN" = '' || "$TARGET_PLAN" =~ ^[0-9a-f]{64}$ ]] || { echo 'Invalid target OS-plan digest' >&2; exit 2; } if [ -z "$VERSION" ]; then echo "Usage: $0 VERSION [remote] [target OS-plan SHA256]" 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" PENDING_MANIFEST="$PROJECT_ROOT/releases/pending/v${VERSION}/manifest.json" LIVE_MANIFEST="$PROJECT_ROOT/releases/manifest.json" if [ -n "$TARGET_PLAN" ]; then MANIFEST="$PROJECT_ROOT/releases/pending/v${VERSION}/targets/$TARGET_PLAN/manifest.json" elif [ -f "$PENDING_MANIFEST" ]; then MANIFEST="$PENDING_MANIFEST" else # Backward compatibility for releases prepared before pending manifests. MANIFEST="$LIVE_MANIFEST" fi fail() { echo "Error: $*" >&2; exit 1; } [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]] || fail 'invalid version' SOURCE_COMMIT=$(python3 "$SCRIPT_DIR/release_payload.py" source --version "$VERSION") # Canonical review and both exact refs precede any API mutation. This script # uploads only; feed promotion is a separately reviewed lead-owned operation. python3 "$SCRIPT_DIR/check-git-mirrors.py" --local --ref "refs/tags/v$VERSION" [ -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" [ "$SOURCE_COMMIT" = "$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["source_commit"])' "$MANIFEST")" ] || fail 'manifest source differs from tagged checkout' [ -f "$BACKEND" ] || fail "backend artifact missing: $BACKEND" [ -f "$FRONTEND" ] || fail "frontend artifact missing: $FRONTEND" "$SCRIPT_DIR/check-release-manifest.sh" "$MANIFEST" OS_ASSET=$(python3 - "$MANIFEST" "$SCRIPT_DIR" "$TARGET_PLAN" <<'PY' import json, sys sys.path.insert(0, sys.argv[2]) from release_payload import component_asset_name m = json.load(open(sys.argv[1])) c = next(c for c in m['components'] if c['name'] == 'os-update-plan.json') name = component_asset_name(c, m['version']) if sys.argv[3]: assert c['sha256'] == sys.argv[3] and name == 'os-update-plan-' + sys.argv[3] + '.json', 'target OS-plan mismatch' print(name) PY ) # Fixed root-controlled appointment + pinned clean build evidence. The candidate # is data, never the verifier. No environment-selected trust anchor or UAT mode. VERIFIED_MANIFEST_SHA=$(sha256sum "$MANIFEST"); VERIFIED_MANIFEST_SHA=${VERIFIED_MANIFEST_SHA%% *} python3 "$SCRIPT_DIR/release_payload.py" production "$MANIFEST" \ --version "$VERSION" --backend "$BACKEND" \ || fail "manifest signature failed cryptographic verification" current=$(sha256sum "$MANIFEST") [ "${current%% *}" = "$VERIFIED_MANIFEST_SHA" ] || fail 'manifest changed during verification' # Complete local ISO authentication before the first publisher API operation. [ "${SKIP_ISO:-0}" = 0 ] || fail 'full release requires ISO verification' ISO=$(ls -t "$PROJECT_ROOT"/image-recipe/results/archipelago-installer-"$VERSION"*-x86_64_RC*.iso 2>/dev/null | head -1 || true) [ -n "$ISO" ] || fail "no production ISO for v$VERSION" ISO_SHA_FILE="$ISO.sha256" ISO_SIG_FILE="$ISO.sha256.json" [ -f "$ISO_SHA_FILE" ] && [ -f "$ISO_SIG_FILE" ] || fail 'missing signed ISO checksums' python3 "$SCRIPT_DIR/release_payload.py" production-iso "$ISO_SIG_FILE" \ --version "$VERSION" --backend "$BACKEND" \ || fail 'ISO signature failed independent production verification' python3 - "$ISO" "$ISO_SIG_FILE" <<'PY' import hashlib, json, pathlib, sys p = pathlib.Path(sys.argv[1]) m = json.load(open(sys.argv[2])) with p.open('rb') as stream: sha = hashlib.file_digest(stream, 'sha256').hexdigest() assert (m['artifact'], m['size_bytes'], m['sha256']) == (p.name, p.stat().st_size, sha), 'signed ISO declaration differs from actual artifact' PY (cd "$(dirname "$ISO")" && sha256sum --check --status "$(basename "$ISO_SHA_FILE")") \ || fail 'ISO checksum mismatch' remote_url=$(git -C "$PROJECT_ROOT" remote get-url "$REMOTE") # Remote URLs are public metadata: ngit can include them in repository # announcements. Keep credentials in Git's credential helper, never in URLs. case "$remote_url" in http://*@*|https://*@*) fail "$REMOTE embeds credentials; move them to a Git credential helper and remove them from the remote URL" ;; http://*|https://*) ;; *) fail "$REMOTE must be an http(s):// Gitea remote URL for API uploads" ;; esac scheme=${remote_url%%://*} host_path=${remote_url#*://} host=${host_path%%/*} repo_path=${host_path#*/} repo_path=${repo_path%.git} credential=$(printf 'url=%s\n\n' "$remote_url" | GIT_TERMINAL_PROMPT=0 git -C "$PROJECT_ROOT" credential fill) \ || fail "no Git credential available for $REMOTE; configure a credential helper first" auth_user="" auth_password="" while IFS= read -r field; do case "$field" in username=*) auth_user=${field#username=} ;; password=*) auth_password=${field#password=} ;; esac done <<< "$credential" [ -n "$auth_user" ] && [ -n "$auth_password" ] \ || fail "Git credential helper did not provide a username and password for $REMOTE" auth="$auth_user:$auth_password" unset credential auth_user auth_password api="$scheme://$host/api/v1/repos/$repo_path" release_url="$api/releases/tags/v${VERSION}" # The manifest is the fleet trigger. Source refs were already mirrored above; # this uploader never changes those refs or the feed, even after verification. echo "Using already mirrored annotated tag v${VERSION}; uploads do not promote the feed." 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 [max_seconds] # The 900s default is ample for the ~98MB frontend tarball but nowhere near # enough for a multi-GB ISO, which also deserves a visible progress bar # rather than sitting mute for the better part of an hour. upload_asset() { local path="$1" local name="$2" local max_time="${3:-900}" if printf '%s\n' "$asset_names" | grep -Fxq "$name"; then echo "Asset $name already exists; leaving it in place." return fi local noise=(--silent) if [ "$max_time" -gt 900 ]; then noise=(--progress-bar); fi echo "Uploading $name ($(du -h "$path" | cut -f1))..." curl --fail --show-error "${noise[@]}" --http1.1 --connect-timeout 20 --max-time "$max_time" \ -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" upload_asset "$VERSION_DIR/$OS_ASSET" "$OS_ASSET" echo "Verifying public download URLs (full GET + size + sha256)..." # Delegated to check-release-assets.sh so the same verifier is used here and by # 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" "$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." "$PROJECT_ROOT/scripts/check-gitea-release-download-links.sh" "$VERSION" \ "archipelago" \ "archipelago-frontend-${VERSION}.tar.gz" \ "$OS_ASSET" \ || fail "release page download links are not public HTTPS URLs — fix Gitea ROOT_URL/proxy configuration before publishing." echo "OTA uploads verified on $REMOTE. Public feed remains unchanged." # ISO was independently authenticated before any publisher API operation. ISO_NAME=$(basename "$ISO") # 4h ceiling: a multi-GB image over a domestic uplink is not a 15-minute job. upload_asset "$ISO" "$ISO_NAME" 14400 upload_asset "$ISO_SHA_FILE" "$ISO_NAME.sha256" upload_asset "$ISO_SIG_FILE" "$ISO_NAME.sha256.json" # First check stored sizes; full public byte verification follows below. echo "Verifying uploaded ISO assets..." assets_json=$(curl -fsS -u "$auth" "$api/releases/$release_id/assets") python3 - "$assets_json" \ "$ISO_NAME" "$(stat -c%s "$ISO")" \ "$ISO_NAME.sha256" "$(stat -c%s "$ISO_SHA_FILE")" \ "$ISO_NAME.sha256.json" "$(stat -c%s "$ISO_SIG_FILE")" <<'PY' \ || fail "ISO asset verification failed — the release is missing or has a truncated ISO" import json import sys assets = {a["name"]: a for a in json.loads(sys.argv[1])} args = sys.argv[2:] bad = [] for name, size in zip(args[0::2], args[1::2]): asset = assets.get(name) if asset is None: bad.append(f"{name}: missing from the release") elif int(asset["size"]) != int(size): bad.append(f"{name}: uploaded {asset['size']} bytes, local file is {size}") else: print(f" OK {name} ({asset['size']} bytes)") for b in bad: print(" FAIL " + b, file=sys.stderr) sys.exit(1 if bad else 0) PY "$PROJECT_ROOT/scripts/check-gitea-release-download-links.sh" "$VERSION" \ "$ISO_NAME" \ "$ISO_NAME.sha256" \ "$ISO_NAME.sha256.json" \ || fail "ISO is uploaded but the release page links are not public HTTPS URLs — fix Gitea ROOT_URL/proxy configuration." # Stored sizes alone do not prove that an existing attachment has the correct # bytes. Download each public asset, including the ISO, before reporting success. BASE_URL=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["components"][0]["download_url"].rsplit("/",1)[0])' "$MANIFEST") for asset in "$ISO" "$ISO_SHA_FILE" "$ISO_SIG_FILE"; do expected=$(sha256sum "$asset"); expected=${expected%% *} actual=$(curl --fail --show-error --location --proto '=https' --proto-redir '=https' \ --connect-timeout 20 --max-time 14400 "$BASE_URL/$(basename "$asset")" | sha256sum) [ "${actual%% *}" = "$expected" ] || fail "public ISO asset byte mismatch: $(basename "$asset")" done echo "ISO for v${VERSION} published and verified on $REMOTE." if [ -n "$TARGET_PLAN" ]; then MANIFEST_ASSET="manifest-$TARGET_PLAN.json" current=$(sha256sum "$MANIFEST") [ "${current%% *}" = "$VERIFIED_MANIFEST_SHA" ] || fail 'verified target manifest changed before upload' upload_asset "$MANIFEST" "$MANIFEST_ASSET" actual=$(curl --fail --show-error --location --proto '=https' --proto-redir '=https' \ --connect-timeout 20 --max-time 120 "$BASE_URL/$MANIFEST_ASSET" | sha256sum) [ "${actual%% *}" = "$VERIFIED_MANIFEST_SHA" ] || fail 'target manifest public byte mismatch' "$PROJECT_ROOT/scripts/check-gitea-release-download-links.sh" "$VERSION" "$MANIFEST_ASSET" \ || fail 'target manifest download link is not public HTTPS' echo "Target-specific signed manifest published and verified: $MANIFEST_ASSET" fi echo 'Feed promotion remains a separate canonical reviewed commit after acceptance and public byte verification.'