#!/usr/bin/env bash # # Scarf release pipeline — local, manual, repeatable. # # Usage: # ./scripts/release.sh 1.7.0 # full release: build, sign, notarize, # # appcast push, GitHub release, tag # ./scripts/release.sh 1.7.0 --draft # everything builds + notarizes, but the # # GitHub release is created as draft, the # # appcast is NOT updated, and main is NOT # # tagged. Promote later with --promote. # # Release notes: # If `releases/v/RELEASE_NOTES.md` exists, it is committed alongside the # version bump and used as the GitHub release body. Otherwise a minimal autogenerated # note is used. # # Prerequisites (one-time setup): # 1. Developer ID Application cert installed in login Keychain. # security find-identity -v -p codesigning | grep "Developer ID Application" # 2. App Store Connect API key stored for notarytool as profile "scarf-notary": # xcrun notarytool store-credentials "scarf-notary" \ # --key ~/.private/AuthKey_XXXX.p8 --key-id --issuer # 3. Sparkle EdDSA keypair generated (private key in Keychain item "https://sparkle-project.org"): # ./scripts/sparkle/generate_keys # or similar, from Sparkle SPM artifacts # 4. gh-pages branch exists with an appcast.xml and GitHub Pages enabled. # 5. gh CLI authed: `gh auth status`. # 6. GH_PAGES_WORKTREE env var pointing at a gh-pages checkout, OR let the # script create one automatically at .gh-pages-worktree/ via `git worktree add`. # set -euo pipefail # ---------- arg parsing ---------- VERSION="" DRAFT=0 for arg in "$@"; do case "$arg" in --draft) DRAFT=1 ;; -h|--help) sed -n '2,30p' "$0"; exit 0 ;; -*) printf '[ERR] unknown flag: %s\n' "$arg" >&2; exit 1 ;; *) [[ -z "$VERSION" ]] && VERSION="$arg" || { printf '[ERR] unexpected arg: %s\n' "$arg" >&2; exit 1; } ;; esac done [[ -n "$VERSION" ]] || { printf 'usage: ./scripts/release.sh [--draft]\n' >&2; exit 1; } # ---------- config ---------- TEAM_ID="3Q6X2L86C4" BUNDLE_ID="com.scarf.app" SCHEME="scarf" PROJECT="scarf/scarf.xcodeproj" NOTARY_PROFILE="scarf-notary" SIGNING_IDENTITY="Developer ID Application" APPCAST_URL="https://awizemann.github.io/scarf/appcast.xml" DOWNLOAD_URL_BASE="https://github.com/awizemann/scarf/releases/download" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" BUILD_DIR="$REPO_ROOT/build" EXPORT_OPTIONS="$REPO_ROOT/scripts/ExportOptions.plist" RELEASE_DIR="$REPO_ROOT/releases/v${VERSION}" GH_PAGES_WORKTREE="${GH_PAGES_WORKTREE:-$REPO_ROOT/.gh-pages-worktree}" # ---------- helpers ---------- log() { printf '\033[1;34m==> %s\033[0m\n' "$*"; } warn() { printf '\033[1;33m[WARN] %s\033[0m\n' "$*" >&2; } die() { printf '\033[1;31m[ERR] %s\033[0m\n' "$*" >&2; exit 1; } require_cmd() { command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1"; } # ---------- preflight ---------- log "Preflight checks" require_cmd xcodebuild require_cmd xcrun require_cmd ditto require_cmd gh cd "$REPO_ROOT" # git must be clean and on main. The one exception: the release dir # (releases/v/) may already exist and be untracked — the user may # have written RELEASE_NOTES.md there ahead of time, and the rest of the dir # is auto-populated + gitignored anyway. Git status abbreviates to the dir # path when all contents are untracked, so the whitelist matches both forms. ALLOW="^\?\? releases/v${VERSION}/" DIRTY="$(git status --porcelain | grep -Ev "$ALLOW" || true)" if [[ -n "$DIRTY" ]]; then die "working tree not clean — commit or stash first" fi CUR_BRANCH="$(git rev-parse --abbrev-ref HEAD)" [[ "$CUR_BRANCH" == "main" ]] || die "not on main (on $CUR_BRANCH)" # identity present security find-identity -v -p codesigning | grep -q "$SIGNING_IDENTITY" \ || die "'$SIGNING_IDENTITY' certificate not in Keychain — create at developer.apple.com" # notary profile present (can't list, only test by dry-running submit help) xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" --output-format json >/dev/null 2>&1 \ || die "notarytool profile '$NOTARY_PROFILE' not set up — see script header" # locate sign_update (ships with Sparkle SPM artifacts) SIGN_UPDATE="$(find ~/Library/Developer/Xcode/DerivedData -name sign_update -type f -perm +111 2>/dev/null | head -n1 || true)" [[ -x "${SIGN_UPDATE:-}" ]] || die "sign_update not found — build the project once in Xcode so Sparkle artifacts resolve, then re-run" # ---------- bump version ---------- log "Bumping version to $VERSION" PBXPROJ="$PROJECT/project.pbxproj" # CURRENT_PROJECT_VERSION (build number) bumps by 1 from existing CUR_BUILD="$(awk -F'= ' '/CURRENT_PROJECT_VERSION/ {gsub(/[; ]/,"",$2); print $2; exit}' "$PBXPROJ")" NEW_BUILD=$((CUR_BUILD + 1)) sed -i '' -E "s/MARKETING_VERSION = [0-9]+\.[0-9]+\.[0-9]+;/MARKETING_VERSION = ${VERSION};/g" "$PBXPROJ" sed -i '' -E "s/CURRENT_PROJECT_VERSION = [0-9]+;/CURRENT_PROJECT_VERSION = ${NEW_BUILD};/g" "$PBXPROJ" git add "$PBXPROJ" # Include release notes in the bump commit if user prepared them ahead of time. NOTES_FILE="$RELEASE_DIR/RELEASE_NOTES.md" if [[ -f "$NOTES_FILE" ]]; then git add "$NOTES_FILE" fi git commit -m "chore: Bump version to ${VERSION}" # ---------- build variants ---------- # Each release produces two zips: a Universal binary (recommended — works on # both Apple Silicon and Intel) and an ARM64-only variant (smaller download for # users who know they're on M-series silicon). Each variant is independently # notarized and stapled. The appcast only references the Universal zip since # it works everywhere; ARM64 is an alternative manual download. log "Clean build directory" rm -rf "$BUILD_DIR" mkdir -p "$BUILD_DIR" "$RELEASE_DIR" # build_variant