#!/usr/bin/env bash # # Publish a channel's Sparkle artifacts (the signed/notarized DMG[s] + the EdDSA-signed # appcast) to the R2 bucket served at updates.nucleic.blakeslee.xyz. The appcast already # carries the update signatures, so this is plain static hosting — a bucket object PUT. # # Object layout: # /.dmg DMGs, foldered by channel for tidy download links # (e.g. beta/Nucleic-Beta-0.1.0.828.dmg) # appcast-.xml Sparkle feed at the bucket root (build's baked-in SUFeedURL) # /latest 302 → newest DMG, served by the cloud/nucleic-updates Worker # # scripts/upload-r2.sh {canary|beta|rc|stable} # # Env: # NUCLEIC_R2_BUCKET target R2 bucket (default: nucleic-updates) # NUCLEIC_FEED_BASE feed host, used for the printed verify URL AND the # already-uploaded existence check (default: https://updates.nucleic.blakeslee.xyz) # NUCLEIC_R2_FORCE re-upload every DMG even if already present (default 0) # WRANGLER wrangler invocation (default: "npx --yes wrangler") # # Auth: wrangler must be logged in (`wrangler login`) or CLOUDFLARE_API_TOKEN set with R2 write. set -euo pipefail CHANNEL="${1:?usage: upload-r2.sh (canary|beta|rc|stable)}" case "$CHANNEL" in canary) SLUG="Nucleic-Canary" ;; beta) SLUG="Nucleic-Beta" ;; rc) SLUG="Nucleic-RC" ;; stable) SLUG="Nucleic" ;; *) echo "usage: $0 {canary|beta|rc|stable}" >&2; exit 2 ;; esac ROOT="$(cd "$(dirname "$0")/.." && pwd)" BUCKET="${NUCLEIC_R2_BUCKET:-nucleic-updates}" FEED_BASE="${NUCLEIC_FEED_BASE:-https://updates.nucleic.blakeslee.xyz}" WRANGLER="${WRANGLER:-npx --yes wrangler}" APPCAST="$ROOT/dist/appcast-$CHANNEL.xml" [ -f "$APPCAST" ] || { echo "upload-r2: $APPCAST missing — run generate-appcast.sh $CHANNEL first" >&2; exit 1; } shopt -s nullglob dmgs=("$ROOT/dist/${SLUG}-"*.dmg) shopt -u nullglob [ "${#dmgs[@]}" -gt 0 ] || { echo "upload-r2: no ${SLUG}-*.dmg in dist/ — build a release first" >&2; exit 1; } put() { # echo " ↑ $2" $WRANGLER r2 object put "$BUCKET/$2" --file "$1" \ --content-type "$3" --cache-control "$4" --remote } # already_uploaded — true if the object is already served at FEED_BASE. DMGs are immutable # and content-addressed by version, so once a key exists its bytes never change; a cheap HEAD # (no body transfer) against the public host lets us skip re-PUTting it. Requires curl; if curl is # absent or NUCLEIC_R2_FORCE=1, we don't skip (fall back to uploading). already_uploaded() { # [ "${NUCLEIC_R2_FORCE:-0}" = "1" ] && return 1 command -v curl >/dev/null 2>&1 || return 1 curl -fsI -o /dev/null "$FEED_BASE/$1" } echo "▸ Uploading $CHANNEL → r2://$BUCKET" # DMGs go under the channel folder (key "/.dmg") for tidy, predictable download # links — updates.nucleic.blakeslee.xyz//.dmg — matching the enclosure URLs # generate-appcast.sh wrote (--download-url-prefix "$FEED_BASE/$CHANNEL/"). They're # content-addressed by version → cache forever, so prior releases already in the bucket are skipped # (they never change): only genuinely new DMGs upload, sparing egress + ops on every re-release. for dmg in "${dmgs[@]}"; do key="$CHANNEL/$(basename "$dmg")" if already_uploaded "$key"; then echo " = $key (already uploaded — skipping)" continue fi put "$dmg" "$key" "application/octet-stream" "public, max-age=31536000, immutable" done # The appcast (the Sparkle feed) stays at the bucket root, where each build's baked-in SUFeedURL # points it. It changes each release → short cache so updates surface quickly. put "$APPCAST" "appcast-$CHANNEL.xml" "application/xml" "public, max-age=300" echo "✓ Uploaded $CHANNEL to r2://$BUCKET" echo " Verify: curl -I $FEED_BASE/appcast-$CHANNEL.xml" echo " curl -I $FEED_BASE/$CHANNEL/$(basename "${dmgs[0]}")" echo " curl -I $FEED_BASE/$CHANNEL/latest # → 302 to the newest DMG"