#!/usr/bin/env bash # Prowl release script: bump, build, sign, notarize, and publish. # # Usage: ./scripts/release.sh [VERSION] # # Prerequisites: # Run ./scripts/release-notes.sh first to generate and review # build/release-notes.md. This script will refuse to proceed without it. # # Environment variables: # APPLE_SIGNING_IDENTITY Developer ID identity (auto-detected if unset) # APPLE_TEAM_ID Apple Team ID (inferred from identity if unset) # APPLE_NOTARY_KEYCHAIN_PROFILE Keychain profile for notarytool (default: supacode-notary) # SPARKLE_PRIVATE_KEY_FILE Path to EdDSA private key file (default: ~/.prowl-sparkle-private-key) # NETLIFY_BUILD_HOOK Netlify Build Hook URL for Prowl-Site rebuild # SKIP_SENTRY Set to 1 to skip dSYM upload and Sentry release tracking # FORCE_RERELEASE Set to 1 to replace a version that already has a GitHub Release set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$PROJECT_DIR" # Load .env if present (not committed to repo) if [[ -f "$PROJECT_DIR/.env" ]]; then set -a source "$PROJECT_DIR/.env" set +a fi # ── Helpers ────────────────────────────────────────────────────────────────── origin_repo_from_remote() { local remote_url remote_url="$(git remote get-url origin 2>/dev/null || true)" [[ -z "$remote_url" ]] && return 1 local repo repo="$(echo "$remote_url" | sed -E 's#^(git@github.com:|ssh://git@github.com/|https://github.com/)##; s#\.git$##')" [[ "$repo" == */* ]] && echo "$repo" && return 0 return 1 } default_signing_identity() { security find-identity -v -p codesigning 2>/dev/null \ | awk -F'"' '/Developer ID Application/ {print $2; exit}' } team_id_from_identity() { local identity="$1" if [[ "$identity" =~ \(([A-Z0-9]{10})\)$ ]]; then echo "${BASH_REMATCH[1]}" fi } signing_identity_sha() { security find-identity -v -p codesigning 2>/dev/null \ | grep "$1" | head -1 | awk '{print $2}' } log() { echo "[release] $*"; } die() { echo "error: $*" >&2; exit 1; } version_date_compact() { # Print the YYYYMMDD date encoded in a YYYY.M.DD[.N] version string. # Fails when the components do not form a real calendar date (BSD date # either rejects the input or normalizes it, so a round-trip mismatch # catches rollovers like 2026.2.30 -> 2026-03-02). local year month day compact roundtrip IFS='.' read -r year month day _ <<<"$1" compact="$(printf '%04d%02d%02d' "$((10#$year))" "$((10#$month))" "$((10#$day))")" roundtrip="$(date -j -f '%Y%m%d' "$compact" +%Y%m%d 2>/dev/null)" || return 1 [[ "$roundtrip" == "$compact" ]] || return 1 echo "$compact" } ensure_release_not_published() { # A GitHub Release for this tag means the version already shipped. Re-running # would delete and re-create the release and force-move the published tag, so # a mistyped old version must not silently clobber a live release. local repo="$1" tag="$2" if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then if [[ "${FORCE_RERELEASE:-}" != "1" ]]; then die "GitHub Release $tag already exists on $repo — refusing to overwrite a published release (set FORCE_RERELEASE=1 to replace it intentionally)" fi log "FORCE_RERELEASE=1 — existing release $tag will be replaced" fi } # ── Preflight ──────────────────────────────────────────────────────────────── log "preflight checks..." [[ "$(uname -s)" == "Darwin" ]] || die "macOS only" for cmd in gh jq codesign xcrun create-dmg; do command -v "$cmd" >/dev/null 2>&1 || die "$cmd is required but not found" done [[ -x "$PROJECT_DIR/bins/generate_appcast" ]] || die "bins/generate_appcast not found" SENTRY_ENABLED=1 if [[ "${SKIP_SENTRY:-}" == "1" ]]; then SENTRY_ENABLED=0 log "SKIP_SENTRY=1 set, dSYM upload and release tracking will be skipped" elif ! command -v sentry-cli >/dev/null 2>&1; then SENTRY_ENABLED=0 log "WARNING: sentry-cli not installed; dSYM upload will be skipped" log " install: brew install getsentry/tools/sentry-cli" log " suppress: SKIP_SENTRY=1" fi if [[ -n "$(git status --porcelain)" ]]; then die "working tree is not clean — commit or stash changes first" fi # Releasing from a stale checkout starts the build-number monotonicity guard # from an outdated CURRENT_PROJECT_VERSION and merges outdated appcast history, # which can publish a Sparkle item that sorts below the live latest build. log "checking checkout freshness against origin/main..." git fetch --quiet origin || die "cannot fetch origin — network is required for a release" git rev-parse --verify --quiet origin/main >/dev/null || die "origin/main not found — cannot verify checkout freshness" if ! git merge-base --is-ancestor origin/main HEAD; then die "HEAD does not contain origin/main — sync your checkout (git pull) before releasing" fi REPO="${GH_REPO:-$(origin_repo_from_remote || true)}" [[ -z "$REPO" ]] && REPO="$(gh repo view --json nameWithOwner -q .nameWithOwner)" [[ -z "$REPO" ]] && die "cannot determine GitHub repository" KEYCHAIN_PROFILE="${APPLE_NOTARY_KEYCHAIN_PROFILE:-supacode-notary}" SIGNING_IDENTITY="${APPLE_SIGNING_IDENTITY:-$(default_signing_identity || true)}" [[ -z "$SIGNING_IDENTITY" ]] && die "no Developer ID Application identity found — set APPLE_SIGNING_IDENTITY" TEAM_ID="${APPLE_TEAM_ID:-$(team_id_from_identity "$SIGNING_IDENTITY" || true)}" [[ -z "$TEAM_ID" ]] && die "cannot determine Apple Team ID — set APPLE_TEAM_ID" IDENTITY_SHA="$(signing_identity_sha "$SIGNING_IDENTITY")" [[ -z "$IDENTITY_SHA" ]] && die "cannot find signing identity SHA for: $SIGNING_IDENTITY" SPARKLE_KEY_FILE="${SPARKLE_PRIVATE_KEY_FILE:-$HOME/.prowl-sparkle-private-key}" [[ -f "$SPARKLE_KEY_FILE" ]] || die "Sparkle private key not found: $SPARKLE_KEY_FILE" NOTES_FILE="build/release-notes.md" [[ -s "$NOTES_FILE" ]] || die "$NOTES_FILE not found — run release-notes.sh first" # Section headings must be `### New` / `### Fixed` / `### Improved` so they # render as

on Prowl-Site (its CSS targets `:global(h3)`) and sit one # level below the `## [VERSION]` header that this script prepends. Reject # bold-paragraph pseudo-headings and `## …` headings — both render unstyled. if bad_lines="$(grep -nE '^(\*\*(New|Fixed|Improved)\*\*|## (New|Fixed|Improved))[[:space:]]*$' "$NOTES_FILE")"; then echo "error: invalid section headings in $NOTES_FILE:" >&2 echo "$bad_lines" | sed 's/^/ /' >&2 die "use '### New' / '### Fixed' / '### Improved' (level-3 headings) instead" fi log "repository: $REPO" log "signing identity: $SIGNING_IDENTITY" log "team ID: $TEAM_ID" # ── Version ────────────────────────────────────────────────────────────────── if [[ -n "${1:-}" ]]; then VERSION="$1" if ! echo "$VERSION" | grep -qE '^[0-9]{4}\.[0-9]{1,2}\.[0-9]{1,2}(\.[0-9]+)?$'; then die "VERSION must be in YYYY.M.DD or YYYY.M.DD.N format" fi VERSION_DATE="$(version_date_compact "$VERSION")" \ || die "VERSION $VERSION does not encode a real calendar date" TODAY_COMPACT="$(date +%Y%m%d)" if (( VERSION_DATE > TODAY_COMPACT )); then die "VERSION $VERSION is dated in the future (today is $(date +%Y.%-m.%-d)) — refusing to publish a future-dated release" fi if (( VERSION_DATE < TODAY_COMPACT )); then log "note: VERSION $VERSION is dated before today ($(date +%Y.%-m.%-d))" fi else VERSION="$(date +%Y.%-m.%-d)" suffix=1 while git rev-parse "v$VERSION" >/dev/null 2>&1; do suffix=$((suffix + 1)) VERSION="$(date +%Y.%-m.%-d).$suffix" done fi TAG="v$VERSION" ensure_release_not_published "$REPO" "$TAG" BUILD="$(date +%Y%m%d)" CURRENT_BUILD="$(/usr/bin/awk -F' = ' '/CURRENT_PROJECT_VERSION = [0-9]+;/{gsub(/;/,""); print $2; exit}' "$PROJECT_DIR/supacode.xcodeproj/project.pbxproj")" if [[ "$CURRENT_BUILD" -ge "$BUILD" ]] 2>/dev/null; then BUILD="$((CURRENT_BUILD + 1))" fi log "version: $VERSION (build $BUILD), tag: $TAG" # ── Bump version (skip if tag already exists) ──────────────────────────────── if git rev-parse "$TAG" >/dev/null 2>&1; then log "tag $TAG already exists, skipping bump" else log "bumping version in project..." make bump-version VERSION="$VERSION" BUILD="$BUILD" fi # ── Update CHANGELOG ──────────────────────────────────────────────────────── CHANGELOG="CHANGELOG.md" ENTRY_HEADER="## [$VERSION](https://github.com/$REPO/releases/tag/$TAG)" if [[ -f "$CHANGELOG" ]] && grep -qF "$ENTRY_HEADER" "$CHANGELOG"; then log "CHANGELOG already contains entry for $VERSION, skipping" else log "updating CHANGELOG.md..." { echo "# Changelog" echo "" echo "$ENTRY_HEADER" echo "" cat "$NOTES_FILE" echo "" if [[ -f "$CHANGELOG" ]]; then # Skip the "# Changelog" header and leading blank line tail -n +3 "$CHANGELOG" fi } > "${CHANGELOG}.tmp" mv "${CHANGELOG}.tmp" "$CHANGELOG" git add "$CHANGELOG" git commit -m "Update CHANGELOG for $VERSION" # Move tag to include the CHANGELOG commit git tag -f "$TAG" HEAD fi # ── Show release notes ─────────────────────────────────────────────────────── echo echo "──── Release Notes ────" cat "$NOTES_FILE" echo "───────────────────────" echo # Confirm interactively if possible if [[ -t 0 ]]; then read -rp "Proceed with release? [Y/n] " confirm case "${confirm:-Y}" in [Yy]*) ;; *) die "release aborted by user" ;; esac fi # ── Archive ────────────────────────────────────────────────────────────────── log "archiving Release build..." make archive APPLE_TEAM_ID="$TEAM_ID" DEVELOPER_ID_IDENTITY_SHA="$IDENTITY_SHA" # ── Sentry: register release + upload dSYM ────────────────────────────────── # Done right after archive so dSYM is ready before downstream steps. Failures # only warn — release proceeds because dSYM can be re-uploaded later with # `sentry-cli debug-files upload `. SENTRY_RELEASE_NAME="prowl@$VERSION" if [[ "$SENTRY_ENABLED" -eq 1 ]]; then log "creating Sentry release $SENTRY_RELEASE_NAME..." sentry-cli releases new "$SENTRY_RELEASE_NAME" \ || log "WARNING: failed to create Sentry release (continuing)" DSYM_DIR="build/supacode.xcarchive/dSYMs" if [[ -d "$DSYM_DIR" ]]; then log "uploading dSYM from $DSYM_DIR to Sentry..." sentry-cli debug-files upload --include-sources --wait "$DSYM_DIR" \ || log "WARNING: dSYM upload failed (release will continue; re-run sentry-cli debug-files upload later)" else log "WARNING: $DSYM_DIR not found, skipping dSYM upload" fi # Sparkle ships as a prebuilt binaryTarget (Sparkle.xcframework), so Xcode never emits a # dSYM for it into the archive — the archive-dSYM upload above therefore never covers # Sparkle. Upload the dSYMs bundled inside the xcframework directly so each new Sparkle # version is symbolicated automatically, instead of relying on a one-off manual bulk upload. SPARKLE_XCFRAMEWORK="$HOME/Library/Caches/supacode-spm-cache/SourcePackages/artifacts/sparkle/Sparkle/Sparkle.xcframework" if [[ -d "$SPARKLE_XCFRAMEWORK" ]]; then log "uploading Sparkle xcframework dSYMs to Sentry..." sentry-cli debug-files upload --wait "$SPARKLE_XCFRAMEWORK" \ || log "WARNING: Sparkle dSYM upload failed (release will continue)" else log "WARNING: $SPARKLE_XCFRAMEWORK not found, skipping Sparkle dSYM upload" fi log "associating commits with Sentry release..." sentry-cli releases set-commits "$SENTRY_RELEASE_NAME" --auto \ || log "WARNING: failed to associate commits (continuing)" fi # ── Export ─────────────────────────────────────────────────────────────────── log "generating ExportOptions.plist..." cat > build/ExportOptions.plist < method developer-id signingStyle manual signingCertificate $SIGNING_IDENTITY teamID $TEAM_ID PLIST log "exporting archive..." make export-archive # ── Locate exported app ───────────────────────────────────────────────────── APP_PATH="$(find build/export -name "*.app" -maxdepth 3 -print -quit)" [[ -d "$APP_PATH" ]] || die "exported app not found in build/export" APP_NAME="$(basename "$APP_PATH")" log "exported app: $APP_PATH" # ── Re-sign Sparkle & Sentry frameworks ───────────────────────────────────── log "re-signing embedded frameworks..." SPARKLE="$APP_PATH/Contents/Frameworks/Sparkle.framework/Versions/B" if [[ -d "$SPARKLE" ]]; then codesign -f -s "$IDENTITY_SHA" -o runtime --timestamp -v "$SPARKLE/XPCServices/Installer.xpc" codesign -f -s "$IDENTITY_SHA" -o runtime --timestamp --preserve-metadata=entitlements -v "$SPARKLE/XPCServices/Downloader.xpc" codesign -f -s "$IDENTITY_SHA" -o runtime --timestamp -v "$SPARKLE/Updater.app" codesign -f -s "$IDENTITY_SHA" -o runtime --timestamp -v "$SPARKLE/Autoupdate" codesign -f -s "$IDENTITY_SHA" -o runtime --timestamp -v "$SPARKLE/Sparkle" codesign -f -s "$IDENTITY_SHA" -o runtime --timestamp -v "$APP_PATH/Contents/Frameworks/Sparkle.framework" fi SENTRY_FRAMEWORK="$APP_PATH/Contents/Frameworks/Sentry.framework" if [[ -d "$SENTRY_FRAMEWORK" ]]; then codesign -f -s "$IDENTITY_SHA" -o runtime --timestamp -v "$SENTRY_FRAMEWORK/Versions/A/Sentry" codesign -f -s "$IDENTITY_SHA" -o runtime --timestamp -v "$SENTRY_FRAMEWORK" fi # ── Re-sign bundled CLI ───────────────────────────────────────────────────── PROWL_CLI="$APP_PATH/Contents/Resources/prowl-cli/prowl" if [[ -f "$PROWL_CLI" ]]; then log "re-signing bundled prowl CLI with hardened runtime..." codesign -f -s "$IDENTITY_SHA" -o runtime --timestamp -v "$PROWL_CLI" fi # ── Re-sign app ───────────────────────────────────────────────────────────── log "re-signing app..." codesign -f -s "$IDENTITY_SHA" -o runtime --timestamp --preserve-metadata=entitlements,requirements,flags -v "$APP_PATH" codesign -vvv --deep --strict "$APP_PATH" log "signature verified" # ── DMG ────────────────────────────────────────────────────────────────────── log "building DMG..." DMG_PATH="build/Prowl.dmg" mise exec -- create-dmg "$APP_PATH" build/ \ --overwrite \ --dmg-title="Prowl" \ --no-code-sign DMG_OUTPUT="$(find build -name "*.dmg" -maxdepth 1 -newer build/ExportOptions.plist | head -1)" if [[ "$DMG_OUTPUT" != "$DMG_PATH" ]] && [[ -n "$DMG_OUTPUT" ]]; then mv "$DMG_OUTPUT" "$DMG_PATH" fi [[ -f "$DMG_PATH" ]] || die "DMG not found at $DMG_PATH" log "compressing DMG with LZMA..." bash "$SCRIPT_DIR/recompress-dmg.sh" "$DMG_PATH" "$DMG_PATH" codesign -s "$IDENTITY_SHA" --timestamp "$DMG_PATH" codesign --verify --strict "$DMG_PATH" # ── Notarize ───────────────────────────────────────────────────────────────── log "notarizing DMG..." for attempt in 1 2 3; do if xcrun notarytool submit "$DMG_PATH" --keychain-profile "$KEYCHAIN_PROFILE" --wait; then break fi if [[ $attempt -lt 3 ]]; then log "notarization attempt $attempt failed, retrying in 30s..." sleep 30 else die "notarization failed after 3 attempts" fi done log "stapling notarization ticket..." xcrun stapler staple "$DMG_PATH" xcrun stapler staple "$APP_PATH" # ── Package zip for Sparkle ────────────────────────────────────────────────── ZIP_PATH="build/Prowl.app.zip" log "packaging $ZIP_PATH for Sparkle..." ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ZIP_PATH" # ── Appcast ────────────────────────────────────────────────────────────────── log "generating appcast..." STAGING="$(mktemp -d)" ARCHIVE_BASE="$(basename "$ZIP_PATH" .zip)" cp "$ZIP_PATH" "$STAGING/" cp "$NOTES_FILE" "$STAGING/$ARCHIVE_BASE.md" # Fetch existing appcast from the latest GitHub release for version history curl -fsSL "https://github.com/$REPO/releases/latest/download/appcast.xml" -o "$STAGING/appcast.xml" 2>/dev/null || true "$PROJECT_DIR/bins/generate_appcast" \ --ed-key-file "$SPARKLE_KEY_FILE" \ --download-url-prefix "https://github.com/$REPO/releases/download/$TAG/" \ --embed-release-notes \ --maximum-versions 10 \ "$STAGING" cp "$STAGING/appcast.xml" build/appcast.xml find "$STAGING" -name "*.delta" -exec cp {} build/ \; 2>/dev/null || true rm -rf "$STAGING" log "appcast generated at build/appcast.xml" # ── Tag + push ─────────────────────────────────────────────────────────────── log "pushing tags..." git push --follow-tags # ── GitHub Release ─────────────────────────────────────────────────────────── log "creating GitHub Release..." UPLOAD_FILES=("$DMG_PATH" "$ZIP_PATH" "build/appcast.xml") DELTA_FILES=( $(find build -name "*.delta" -type f 2>/dev/null || true) ) UPLOAD_FILES+=("${DELTA_FILES[@]}") # Delete existing release if present (idempotent re-run) if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then log "deleting existing release $TAG for re-creation..." gh release delete "$TAG" --repo "$REPO" --yes fi gh release create "$TAG" "${UPLOAD_FILES[@]}" \ --repo "$REPO" \ --title "Prowl $VERSION" \ --notes-file "$NOTES_FILE" RELEASE_URL="https://github.com/$REPO/releases/tag/$TAG" log "release created: $RELEASE_URL" # ── Finalize Sentry release ───────────────────────────────────────────────── # Marks the release as deployed in Sentry's dashboard so issue tracking can # associate "first seen in" with this version. if [[ "$SENTRY_ENABLED" -eq 1 ]]; then log "finalizing Sentry release $SENTRY_RELEASE_NAME..." sentry-cli releases finalize "$SENTRY_RELEASE_NAME" \ || log "WARNING: failed to finalize Sentry release (non-fatal)" fi # ── Trigger Prowl-Site rebuild ─────────────────────────────────────────────── log "triggering Prowl-Site rebuild..." if [[ -n "${NETLIFY_BUILD_HOOK:-}" ]]; then curl -fsSL -X POST "$NETLIFY_BUILD_HOOK" 2>/dev/null \ && log "Prowl-Site rebuild triggered" \ || log "Prowl-Site rebuild trigger failed (non-critical)" else log "NETLIFY_BUILD_HOOK not set, skipping Prowl-Site rebuild" fi echo log "done! Release: $RELEASE_URL"