#!/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 ] [--kernel-ref ] \ # [--distro ] [--arch ] [--description "..."] \ # [--size ] [--platform

] [--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"