265 lines
12 KiB
Bash
Executable File
265 lines
12 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}"
|
|
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'), None)
|
|
if c is None:
|
|
assert not sys.argv[3] and m.get('application_update') in ({'version': 1, 'mode': 'signed-application-only'}, {'version': 2, 'mode': 'signed-application-runtime'}), 'public application policy required'
|
|
print('')
|
|
raise SystemExit(0)
|
|
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 <path> <name> [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"
|
|
COMPONENT_ASSETS=("archipelago" "archipelago-frontend-${VERSION}.tar.gz")
|
|
if [ -n "$OS_ASSET" ]; then
|
|
upload_asset "$VERSION_DIR/$OS_ASSET" "$OS_ASSET"
|
|
COMPONENT_ASSETS+=("$OS_ASSET")
|
|
fi
|
|
|
|
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; public feed promotion remains blocked"
|
|
|
|
"$PROJECT_ROOT/scripts/check-gitea-release-download-links.sh" "$VERSION" \
|
|
"${COMPONENT_ASSETS[@]}" \
|
|
|| 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"
|
|
else
|
|
MANIFEST_ASSET="manifest.json"
|
|
fi
|
|
current=$(sha256sum "$MANIFEST")
|
|
[ "${current%% *}" = "$VERIFIED_MANIFEST_SHA" ] || fail 'verified 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 'manifest public byte mismatch'
|
|
"$PROJECT_ROOT/scripts/check-gitea-release-download-links.sh" "$VERSION" "$MANIFEST_ASSET" \
|
|
|| fail 'manifest download link is not public HTTPS'
|
|
echo "Signed manifest published and verified: $MANIFEST_ASSET"
|
|
echo 'Feed promotion remains a separate canonical reviewed commit after acceptance and public byte verification.'
|