#!/usr/bin/env bash # Publish a complete M2Image release to a Cloudflare R2-backed registry. set -euo pipefail unset CDPATH script_dir=$(cd -- "$(dirname -- "$0")" && pwd -P) repo_dir=$(cd -- "${script_dir}/.." && pwd -P) manifest=${M2IMAGE_MANIFEST:-${repo_dir}/packaging/m2images.json} dist_dir=${DIST_DIR:-${repo_dir}/dist/m2images} bucket=${R2_BUCKET:-} rclone_remote=${R2_REMOTE:-r2} backend=${R2_BACKEND:-auto} dry_run=0 usage() { cat <<'EOF' Usage: ./scripts/publish-m2images-r2.sh --bucket [options] Options: --bucket Cloudflare R2 bucket (or R2_BUCKET) --backend Upload client (default: auto; prefers rclone) --rclone-remote rclone remote configured for R2 (default: r2) --manifest Alternate release manifest --dist-dir Package root (default: dist/m2images) --dry-run Validate and print uploads without changing R2 -h, --help Show this help The publisher requires every manifest alias for x86_64 and aarch64, including its exact corresponding-source sibling archive. Source archives and M2Images are uploaded before catalog.json, so consumers never discover a release whose source bytes are absent. Configure rclone's S3 provider as Cloudflare for large artifacts. Wrangler supports only files up to 315 MB and is a fallback. EOF } info() { printf '[INFO] %s\n' "$*"; } fail() { printf '[FAIL] %s\n' "$*" >&2; exit 1; } require_command() { command -v "$1" >/dev/null 2>&1 || fail "$1 is required"; } while [ "$#" -gt 0 ]; do case "$1" in -h|--help) usage; exit 0 ;; --bucket) [ "$#" -ge 2 ] || fail 'missing value for --bucket'; bucket=$2; shift 2 ;; --bucket=*) bucket=${1#--bucket=}; shift ;; --backend) [ "$#" -ge 2 ] || fail 'missing value for --backend'; backend=$2; shift 2 ;; --backend=*) backend=${1#--backend=}; shift ;; --rclone-remote) [ "$#" -ge 2 ] || fail 'missing value for --rclone-remote'; rclone_remote=$2; shift 2 ;; --rclone-remote=*) rclone_remote=${1#--rclone-remote=}; shift ;; --manifest) [ "$#" -ge 2 ] || fail 'missing value for --manifest'; manifest=$2; shift 2 ;; --manifest=*) manifest=${1#--manifest=}; shift ;; --dist-dir) [ "$#" -ge 2 ] || fail 'missing value for --dist-dir'; dist_dir=$2; shift 2 ;; --dist-dir=*) dist_dir=${1#--dist-dir=}; shift ;; --dry-run) dry_run=1; shift ;; *) fail "unknown argument: $1" ;; esac done [ -n "$bucket" ] || fail '--bucket or R2_BUCKET is required' case "$bucket" in *[!a-z0-9.-]*|'') fail "invalid R2 bucket name: $bucket" ;; esac case "$backend" in auto|rclone|wrangler) ;; *) fail "unsupported backend: $backend" ;; esac require_command python3 require_command sha256sum python3 "${script_dir}/m2image-manifest.py" --manifest "$manifest" validate >/dev/null if [ "$backend" = auto ]; then if command -v rclone >/dev/null 2>&1; then backend=rclone elif command -v wrangler >/dev/null 2>&1; then backend=wrangler elif [ "$dry_run" -eq 1 ]; then backend=rclone else fail 'install rclone (recommended) or Wrangler v4' fi fi if [ "$dry_run" -eq 0 ]; then require_command "$backend"; fi if [ "$backend" = wrangler ] && [ "$dry_run" -eq 0 ]; then wrangler_version=$(wrangler --version 2>/dev/null | sed -nE 's/.* ([0-9]+)\..*/\1/p' | head -n1) if [ -z "$wrangler_version" ] || [ "$wrangler_version" -lt 4 ]; then fail 'Wrangler v4 or newer is required' fi fi mapfile -t aliases < <(python3 "${script_dir}/m2image-manifest.py" --manifest "$manifest" aliases) mapfile -t architectures < <(python3 "${script_dir}/m2image-manifest.py" --manifest "$manifest" architectures) for architecture in "${architectures[@]}"; do checksum_file="${dist_dir}/${architecture}/SHA256SUMS" [ -f "$checksum_file" ] || fail "checksum file not found: $checksum_file" (cd "${dist_dir}/${architecture}" && sha256sum -c SHA256SUMS) done # Catalog construction is a fail-closed completeness check: it now requires the # binary and source sibling for every alias/architecture and binds both hashes. catalog_tmp=$(mktemp "${dist_dir}/.catalog.XXXXXX") trap 'rm -f -- "$catalog_tmp"' EXIT python3 "${script_dir}/m2image-manifest.py" --manifest "$manifest" catalog \ --dist-dir "$dist_dir" --output "$catalog_tmp" print_command() { printf '[DRY-RUN]' printf ' %q' "$@" printf '\n' } upload() { local source=$1 local key=$2 local content_type=$3 local content_disposition= [ -f "$source" ] || fail "upload source not found: $source" if [ "$content_type" = application/zstd ]; then content_disposition="attachment; filename=\"$(basename -- "$key")\"" fi if [ "$backend" = rclone ]; then command=(rclone copyto "$source" "${rclone_remote}:${bucket}/${key}") [ -z "$content_disposition" ] \ || command+=(--metadata-set "content-disposition=${content_disposition}") [ "$dry_run" -eq 0 ] || command+=(--dry-run) else bytes=$(wc -c <"$source" | tr -d ' ') if [ "$bytes" -gt 315000000 ]; then fail "Wrangler cannot upload ${source} (${bytes} bytes, limit 315 MB); use --backend rclone" fi command=(wrangler r2 object put "${bucket}/${key}" --file "$source" --remote \ --content-type "$content_type") [ -z "$content_disposition" ] \ || command+=(--content-disposition "$content_disposition") [ "$content_type" != application/zstd ] || command+=(--cache-control 'public, max-age=31536000, immutable') fi if [ "$dry_run" -eq 1 ]; then print_command "${command[@]}" else "${command[@]}" fi } for alias in "${aliases[@]}"; do for architecture in "${architectures[@]}"; do source_package="${dist_dir}/${architecture}/${alias}.sources.tar.zst" source_key=$(python3 "${script_dir}/m2image-manifest.py" --manifest "$manifest" \ source-registry-key "$alias" "$architecture") info "uploading exact source ${alias}/${architecture} -> r2://${bucket}/${source_key}" upload "$source_package" "$source_key" application/zstd package="${dist_dir}/${architecture}/${alias}.tar.zst" key=$(python3 "${script_dir}/m2image-manifest.py" --manifest "$manifest" \ registry-key "$alias" "$architecture") info "uploading ${alias}/${architecture} -> r2://${bucket}/${key}" upload "$package" "$key" application/zstd done done catalog_key=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["registry"]["catalogKey"])' "$manifest") info "publishing catalog last -> r2://${bucket}/${catalog_key}" upload "$catalog_tmp" "$catalog_key" application/json info "R2 publication complete (${backend})"