banger/scripts/publish-golden-image.sh
Thales Maciel 75baf2e415
publish-golden-image: content-addressed tarball names
Embed the sha256 prefix in the uploaded filename so every rebuild
lives at a unique URL. Cloudflare's edge cache (and any similar CDN
in front of R2) can never serve stale bytes for the URL the catalog
points at. The R2 console offers no per-URL purge for this bucket
layout, so making the URL itself content-addressed is the only
durable fix.

Also republishes the debian-bookworm catalog entry with the new
filename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:26:57 -03:00

161 lines
5 KiB
Bash
Executable file

#!/usr/bin/env bash
# publish-golden-image.sh
#
# Build the banger golden-image bundle, upload it to R2, and patch
# internal/imagecat/catalog.json with the resulting URL + sha256 +
# size. Mirrors publish-kernel.sh for kernelcat.
#
# Usage:
# scripts/publish-golden-image.sh [--name <n>] [--kernel-ref <k>] \
# [--distro <d>] [--arch <a>] [--description "..."] \
# [--size <spec>] [--platform <p>] [--skip-upload]
#
# Environment overrides:
# RCLONE_REMOTE rclone remote to upload through (default: banger-images)
# RCLONE_BUCKET R2 bucket name (default: banger-images)
# BASE_URL public URL prefix for the bucket (default: https://images.thaloco.com)
set -euo pipefail
log() { printf '[publish-golden-image] %s\n' "$*" >&2; }
die() { log "$*"; exit 1; }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CATALOG_FILE="$REPO_ROOT/internal/imagecat/catalog.json"
RCLONE_REMOTE="${RCLONE_REMOTE:-banger-images}"
RCLONE_BUCKET="${RCLONE_BUCKET:-banger-images}"
BASE_URL="${BASE_URL:-https://images.thaloco.com}"
NAME="debian-bookworm"
KERNEL_REF="generic-6.12"
DISTRO="debian"
ARCH="x86_64"
DESCRIPTION=""
SIZE=""
PLATFORM="linux/amd64"
SKIP_UPLOAD=0
while [[ $# -gt 0 ]]; do
case "$1" in
--name) NAME="${2:-}"; shift 2;;
--kernel-ref) KERNEL_REF="${2:-}"; shift 2;;
--distro) DISTRO="${2:-}"; shift 2;;
--arch) ARCH="${2:-}"; shift 2;;
-d|--description) DESCRIPTION="${2:-}"; shift 2;;
--size) SIZE="${2:-}"; shift 2;;
--platform) PLATFORM="${2:-}"; shift 2;;
--skip-upload) SKIP_UPLOAD=1; shift;;
-h|--help)
sed -n '2,/^set -euo/p' "$0" | sed 's/^# \?//' | sed '$d'
exit 0
;;
*) die "unknown option: $1";;
esac
done
for tool in jq sha256sum stat; do
command -v "$tool" >/dev/null 2>&1 || die "missing required tool: $tool"
done
[[ -f "$CATALOG_FILE" ]] || die "catalog file not found: $CATALOG_FILE"
if [[ "$SKIP_UPLOAD" -ne 1 ]]; then
for tool in rclone curl; do
command -v "$tool" >/dev/null 2>&1 || die "missing required tool: $tool"
done
fi
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
# Build to a temp name; the content-addressed final name is chosen
# after sha256 is computed.
BUILD_OUT="$STAGE/build.tar.zst"
log "building bundle via make-golden-bundle.sh"
SIZE_FLAG=()
[[ -n "$SIZE" ]] && SIZE_FLAG=(--size "$SIZE")
"$SCRIPT_DIR/make-golden-bundle.sh" \
--name "$NAME" \
--kernel-ref "$KERNEL_REF" \
--distro "$DISTRO" \
--arch "$ARCH" \
--description "$DESCRIPTION" \
--platform "$PLATFORM" \
"${SIZE_FLAG[@]}" \
--out "$BUILD_OUT"
SHA256="$(sha256sum "$BUILD_OUT" | awk '{print $1}')"
SIZE_BYTES="$(stat -c '%s' "$BUILD_OUT")"
HUMAN="$(numfmt --to=iec --suffix=B "$SIZE_BYTES" 2>/dev/null || echo "${SIZE_BYTES}B")"
# Content-addressed filename: every rebuild lives at a unique URL, so
# stale CDN caches can never serve the wrong bytes for the URL the
# catalog points at. First 12 hex chars of sha256 is plenty of
# collision margin for this workload.
SHA_PREFIX="${SHA256:0:12}"
TARBALL_NAME="${NAME}-${ARCH}-${SHA_PREFIX}.tar.zst"
OUT="$STAGE/$TARBALL_NAME"
mv "$BUILD_OUT" "$OUT"
log "bundle ready: $TARBALL_NAME ($HUMAN, sha256 $SHA256)"
if [[ "$SKIP_UPLOAD" -eq 1 ]]; then
KEEP="$REPO_ROOT/dist/$TARBALL_NAME"
mkdir -p "$(dirname "$KEEP")"
cp -f "$OUT" "$KEEP"
log "--skip-upload set; catalog not patched"
log "bundle kept at: $KEEP"
exit 0
fi
log "uploading to $RCLONE_REMOTE:$RCLONE_BUCKET/$TARBALL_NAME"
# --s3-no-check-bucket skips the HeadBucket preflight; --no-check-dest
# skips the HeadObject preflight. Both fail with 403 on R2 tokens that
# only have PutObject + GetObject but not Head* — a common scoped-token
# setup.
rclone copyto \
--s3-no-check-bucket \
--no-check-dest \
"$OUT" "$RCLONE_REMOTE:$RCLONE_BUCKET/$TARBALL_NAME"
URL="$BASE_URL/$TARBALL_NAME"
log "verifying $URL is reachable"
HEAD_STATUS="$(curl -fsSI -o /dev/null -w '%{http_code}' "$URL" || true)"
if [[ "$HEAD_STATUS" != "200" ]]; then
die "uploaded tarball is not publicly reachable at $URL (HTTP $HEAD_STATUS); check bucket public-access config"
fi
log "patching $CATALOG_FILE"
NEW_ENTRY="$(jq -n \
--arg name "$NAME" \
--arg distro "$DISTRO" \
--arg arch "$ARCH" \
--arg kref "$KERNEL_REF" \
--arg url "$URL" \
--arg sha "$SHA256" \
--argjson size "$SIZE_BYTES" \
--arg desc "$DESCRIPTION" \
'{
name: $name,
distro: $distro,
arch: $arch,
kernel_ref: $kref,
tarball_url: $url,
tarball_sha256: $sha,
size_bytes: $size,
description: $desc
} | with_entries(select(.value != null and .value != ""))')"
CATALOG_TMP="$(mktemp)"
jq --arg name "$NAME" --argjson new "$NEW_ENTRY" '
.version = (.version // 1)
| .entries = (((.entries // []) | map(select(.name != $name))) + [$new])
| .entries |= sort_by(.name)
' "$CATALOG_FILE" > "$CATALOG_TMP"
mv "$CATALOG_TMP" "$CATALOG_FILE"
log "done"
log "next steps:"
log " git diff -- $CATALOG_FILE"
log " git add $CATALOG_FILE && git commit -m 'imagecat: publish $NAME'"
log " make build # rebuild banger so the new catalog is embedded"