#!/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" ARCHIVE_PATH="$BUILD_DIR/scarf.xcarchive" EXPORT_DIR="$BUILD_DIR/export" 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 if [[ -n "$(git status --porcelain)" ]]; 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 ---------- log "Clean build directory" rm -rf "$BUILD_DIR" mkdir -p "$BUILD_DIR" log "Archive (universal arm64+x86_64)" xcodebuild \ -project "$PROJECT" \ -scheme "$SCHEME" \ -configuration Release \ -archivePath "$ARCHIVE_PATH" \ -destination "generic/platform=macOS" \ ONLY_ACTIVE_ARCH=NO \ ARCHS="arm64 x86_64" \ archive log "Export signed .app" xcodebuild \ -exportArchive \ -archivePath "$ARCHIVE_PATH" \ -exportPath "$EXPORT_DIR" \ -exportOptionsPlist "$EXPORT_OPTIONS" # Xcode exports as scarf.app (PRODUCT_NAME = $TARGET_NAME = "scarf"). Rename the # wrapper to Scarf.app so users see properly-cased app in /Applications. Renaming # the bundle directory does NOT invalidate the signature (codesign signs contents, # not the wrapper folder name). if [[ -d "$EXPORT_DIR/scarf.app" && ! -d "$EXPORT_DIR/Scarf.app" ]]; then mv "$EXPORT_DIR/scarf.app" "$EXPORT_DIR/Scarf.app" fi APP_PATH="$EXPORT_DIR/Scarf.app" [[ -d "$APP_PATH" ]] || die "exported app not found at $APP_PATH" # ---------- verify signature ---------- log "Verify signature" codesign --verify --deep --strict --verbose=2 "$APP_PATH" # spctl will fail here (not yet notarized) — that's fine, we check after stapling spctl --assess --type execute --verbose "$APP_PATH" || true # ---------- notarize ---------- log "Zip for notarization" NOTARIZE_ZIP="$BUILD_DIR/Scarf-notarize.zip" ditto -c -k --keepParent "$APP_PATH" "$NOTARIZE_ZIP" log "Submit to notarytool (blocking)" xcrun notarytool submit "$NOTARIZE_ZIP" \ --keychain-profile "$NOTARY_PROFILE" \ --wait \ --timeout 30m log "Staple notarization ticket" xcrun stapler staple "$APP_PATH" xcrun stapler validate "$APP_PATH" log "Final gatekeeper assessment" spctl --assess --type execute --verbose "$APP_PATH" # ---------- package distribution artifacts ---------- log "Package distribution zips" mkdir -p "$RELEASE_DIR" UNIVERSAL_ZIP="$RELEASE_DIR/Scarf-v${VERSION}-Universal.zip" ditto -c -k --keepParent "$APP_PATH" "$UNIVERSAL_ZIP" # ---------- sign appcast entry ---------- log "Sign appcast entry with EdDSA" # sign_update prints: sparkle:edSignature="..." length="..." SIG_OUTPUT="$("$SIGN_UPDATE" "$UNIVERSAL_ZIP")" ED_SIGNATURE="$(echo "$SIG_OUTPUT" | sed -nE 's/.*sparkle:edSignature="([^"]+)".*/\1/p')" FILE_LENGTH="$(echo "$SIG_OUTPUT" | sed -nE 's/.*length="([^"]+)".*/\1/p')" [[ -n "$ED_SIGNATURE" && -n "$FILE_LENGTH" ]] || die "sign_update did not produce signature: $SIG_OUTPUT" DOWNLOAD_URL="$DOWNLOAD_URL_BASE/v${VERSION}/Scarf-v${VERSION}-Universal.zip" PUB_DATE="$(LC_TIME=en_US.UTF-8 date -u +"%a, %d %b %Y %H:%M:%S +0000")" APPCAST_ITEM=$(cat < Version ${VERSION} ${NEW_BUILD} ${VERSION} 14.6 ${PUB_DATE} EOF ) # ---------- update appcast on gh-pages (skipped for drafts) ---------- if [[ $DRAFT -eq 0 ]]; then log "Update appcast.xml on gh-pages worktree" if [[ ! -d "$GH_PAGES_WORKTREE" ]]; then git worktree add "$GH_PAGES_WORKTREE" gh-pages fi ( cd "$GH_PAGES_WORKTREE" git pull --ff-only origin gh-pages # Insert new item after en line python3 - "$APPCAST_ITEM" <<'PY' import sys, pathlib new_item = sys.argv[1] p = pathlib.Path("appcast.xml") xml = p.read_text() marker = "en" if marker not in xml: sys.exit("appcast.xml missing en marker") xml = xml.replace(marker, marker + "\n" + new_item, 1) p.write_text(xml) PY git add appcast.xml git commit -m "release: v${VERSION}" git push origin gh-pages ) else log "Draft mode — skipping appcast push. Saving entry to $RELEASE_DIR/appcast-entry.xml for later promotion." printf '%s\n' "$APPCAST_ITEM" > "$RELEASE_DIR/appcast-entry.xml" fi # ---------- github release ---------- log "Create GitHub release and upload artifacts" GH_FLAGS=() [[ $DRAFT -eq 1 ]] && GH_FLAGS+=(--draft) if [[ -f "$NOTES_FILE" ]]; then GH_FLAGS+=(--notes-file "$NOTES_FILE") else GH_FLAGS+=(--notes "Release v${VERSION}. See commit history for details.") fi gh release create "v${VERSION}" \ --title "Scarf v${VERSION}" \ "${GH_FLAGS[@]}" \ "$UNIVERSAL_ZIP" # ---------- tag main (skipped for drafts) ---------- if [[ $DRAFT -eq 0 ]]; then log "Tag main and push" git tag "v${VERSION}" git push origin main --tags else log "Draft mode — skipping tag. Bump commit is local only; push manually with: git push origin main" fi if [[ $DRAFT -eq 1 ]]; then log "Draft release v${VERSION} ready" log " Review: https://github.com/awizemann/scarf/releases" log " Promote: in GitHub UI, edit the draft and uncheck 'Set as a pre-release / draft' → Publish." log " Then commit + push appcast-entry.xml to gh-pages, and tag main:" log " git push origin main" log " git tag v${VERSION} && git push origin v${VERSION}" log " (manually merge $RELEASE_DIR/appcast-entry.xml into gh-pages branch's appcast.xml after en)" else log "Release v${VERSION} complete" log " Download: $DOWNLOAD_URL" log " Appcast: $APPCAST_URL" fi