2026-04-16 18:42:20 -07:00
#!/usr/bin/env bash
#
# Scarf release pipeline — local, manual, repeatable.
#
2026-04-16 19:04:27 -07:00
# 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<VERSION>/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.
2026-04-16 18:42:20 -07:00
#
# 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 <KEY_ID> --issuer <ISSUER_ID>
# 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
2026-04-16 19:04:27 -07:00
# ---------- 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 <marketing-version> [--draft]\n' >& 2; exit 1; }
2026-04-16 18:42:20 -07:00
# ---------- 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 "
2026-04-17 17:17:49 -07:00
# git must be clean and on main. The one exception: the release dir
# (releases/v<VERSION>/) 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
2026-04-16 18:42:20 -07:00
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 "
2026-04-16 19:04:27 -07:00
# 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
2026-04-16 18:42:20 -07:00
git commit -m " chore: Bump version to ${ VERSION } "
2026-04-16 20:10:17 -07:00
# ---------- 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.
2026-04-16 18:42:20 -07:00
log "Clean build directory"
rm -rf " $BUILD_DIR "
2026-04-16 20:10:17 -07:00
mkdir -p " $BUILD_DIR " " $RELEASE_DIR "
2026-04-16 18:42:20 -07:00
2026-04-16 20:10:17 -07:00
# build_variant <label> <archs> <output_zip>
# label e.g. "Universal" or "ARM64" (used as subdir name + log prefix)
# archs e.g. "arm64 x86_64" or "arm64" (space-separated ARCHS value)
# output_zip absolute path where the stapled, distribution-ready zip is written
build_variant( ) {
local label = " $1 "
local archs = " $2 "
local out_zip = " $3 "
local variant_dir = " $BUILD_DIR / $label "
local archive_path = " $variant_dir /scarf.xcarchive "
local export_dir = " $variant_dir /export "
local app_path = " $export_dir /Scarf.app "
local notarize_zip = " $variant_dir /Scarf-notarize.zip "
2026-04-16 18:42:20 -07:00
2026-04-16 20:10:17 -07:00
mkdir -p " $variant_dir "
2026-04-16 18:42:20 -07:00
2026-04-16 20:10:17 -07:00
log " [ $label ] Archive (archs: $archs ) "
xcodebuild \
-project " $PROJECT " \
-scheme " $SCHEME " \
-configuration Release \
-archivePath " $archive_path " \
-destination "generic/platform=macOS" \
ONLY_ACTIVE_ARCH = NO \
ARCHS = " $archs " \
archive
log " [ $label ] Export signed .app "
xcodebuild \
-exportArchive \
-archivePath " $archive_path " \
-exportPath " $export_dir " \
-exportOptionsPlist " $EXPORT_OPTIONS "
2026-04-16 18:42:20 -07:00
2026-04-16 20:10:17 -07:00
# Xcode exports as scarf.app (PRODUCT_NAME = $TARGET_NAME = "scarf"). Rename so
# users see properly-cased Scarf.app in /Applications. Renaming the bundle
# wrapper does NOT invalidate the signature — codesign signs contents, not the
# wrapper folder name.
if [ [ -d " $export_dir /scarf.app " && ! -d " $app_path " ] ] ; then
mv " $export_dir /scarf.app " " $app_path "
fi
[ [ -d " $app_path " ] ] || die " [ $label ] exported app not found at $app_path "
log " [ $label ] Verify signature "
codesign --verify --deep --strict --verbose= 2 " $app_path "
2026-04-16 18:42:20 -07:00
2026-04-16 20:10:17 -07:00
log " [ $label ] Zip for notarization "
ditto -c -k --keepParent " $app_path " " $notarize_zip "
2026-04-16 18:42:20 -07:00
2026-04-16 20:10:17 -07:00
log " [ $label ] Submit to notarytool (blocking) "
xcrun notarytool submit " $notarize_zip " \
--keychain-profile " $NOTARY_PROFILE " \
--wait \
--timeout 30m
2026-04-16 18:42:20 -07:00
2026-04-16 20:10:17 -07:00
log " [ $label ] Staple + validate "
xcrun stapler staple " $app_path "
xcrun stapler validate " $app_path "
spctl --assess --type execute --verbose " $app_path "
2026-04-16 18:42:20 -07:00
2026-04-16 20:10:17 -07:00
log " [ $label ] Package $( basename " $out_zip " ) "
ditto -c -k --keepParent " $app_path " " $out_zip "
2026-04-27 11:40:16 +02:00
# Post-package verification: extract the actual distribution zip and confirm
# codesign + Gatekeeper still accept it. Catches any regression introduced by
# ditto / staple / future pipeline tweaks before users see "damaged" errors.
# See issue #49 — without this, a broken seal in Sparkle.framework or the
# outer bundle would only surface in user reports.
log " [ $label ] Post-package signature + Gatekeeper verification "
local verify_dir
verify_dir = " $( mktemp -d) "
ditto -xk " $out_zip " " $verify_dir "
codesign --verify --strict --deep --verbose= 4 " $verify_dir /Scarf.app " \
|| die " [ $label ] codesign --verify failed on packaged zip "
spctl --assess --type execute --verbose " $verify_dir /Scarf.app " \
|| die " [ $label ] spctl --assess failed on packaged zip "
rm -rf " $verify_dir "
2026-04-16 20:10:17 -07:00
}
2026-04-16 18:42:20 -07:00
UNIVERSAL_ZIP = " $RELEASE_DIR /Scarf-v ${ VERSION } -Universal.zip "
2026-04-16 20:10:17 -07:00
ARM64_ZIP = " $RELEASE_DIR /Scarf-v ${ VERSION } -ARM64.zip "
build_variant "Universal" "arm64 x86_64" " $UNIVERSAL_ZIP "
build_variant "ARM64" "arm64" " $ARM64_ZIP "
2026-04-16 18:42:20 -07:00
# ---------- 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 "
2026-04-16 19:04:27 -07:00
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" ) "
2026-05-05 20:31:27 +02:00
# Render RELEASE_NOTES.md to a Sparkle-friendly inline HTML fragment
# and embed it as <description><![CDATA[…]]></description> on the
# appcast item. Sparkle's standard update alert renders this in a
# WebKit view so users see the release-specific "what's new" inside
# the in-app update sheet, not just the version number. Falls back to
# a placeholder line when the notes file is missing (matches the
# `--notes-file` fallback behavior of `gh release create` below).
RELEASE_NOTES_HTML = ""
if [ [ -f " $NOTES_FILE " ] ] ; then
log " Render in-app release notes from $NOTES_FILE "
RELEASE_NOTES_HTML = " $( python3 " $REPO_ROOT /tools/render-release-notes.py " " $NOTES_FILE " ) "
fi
if [ [ -z " $RELEASE_NOTES_HTML " ] ] ; then
RELEASE_NOTES_HTML = " <p>Release v ${ VERSION } . See the <a href=\"https://github.com/awizemann/scarf/releases/tag/v ${ VERSION } \">GitHub release page</a> for details.</p> "
fi
2026-04-16 19:04:27 -07:00
APPCAST_ITEM = $( cat <<EOF
2026-04-16 18:42:20 -07:00
<item>
<title>Version ${VERSION}</title>
<sparkle:version>${NEW_BUILD}</sparkle:version>
<sparkle:shortVersionString>${VERSION}</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>14.6</sparkle:minimumSystemVersion>
<pubDate>${PUB_DATE}</pubDate>
2026-05-05 20:31:27 +02:00
<description><![CDATA[
${RELEASE_NOTES_HTML}
]]></description>
2026-04-16 18:42:20 -07:00
<enclosure url="${DOWNLOAD_URL}"
sparkle:edSignature="${ED_SIGNATURE}"
length="${FILE_LENGTH}"
type="application/octet-stream" />
</item>
EOF
)
2026-04-16 19:04:27 -07:00
# ---------- 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 <language>en</language> line
python3 - " $APPCAST_ITEM " <<'PY'
2026-04-16 18:42:20 -07:00
import sys, pathlib
new_item = sys.argv[1]
p = pathlib.Path("appcast.xml")
xml = p.read_text()
marker = "<language>en</language>"
if marker not in xml:
sys.exit("appcast.xml missing <language>en</language> marker")
xml = xml.replace(marker, marker + "\n" + new_item, 1)
p.write_text(xml)
PY
2026-04-16 19:04:27 -07:00
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
2026-04-16 18:42:20 -07:00
2026-04-17 17:24:31 -07:00
# ---------- push main + tag (skipped for drafts) ----------
# Order matters: push main and the tag BEFORE `gh release create`, so gh finds
# the tag already on origin and attaches the release to the correct commit. If
# we let gh auto-create the tag, it pins to the then-current origin HEAD —
# which is one commit behind the bump (since we haven't pushed main yet) —
# and the subsequent `git push origin v<VER>` gets rejected as non-fast-forward.
if [ [ $DRAFT -eq 0 ] ] ; then
log "Push main"
git push origin main
log " Tag main at v ${ VERSION } and push tag "
git tag " v ${ VERSION } "
git push origin " v ${ VERSION } "
fi
2026-04-16 18:42:20 -07:00
# ---------- github release ----------
log "Create GitHub release and upload artifacts"
2026-04-16 19:04:27 -07:00
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
2026-04-16 18:42:20 -07:00
gh release create " v ${ VERSION } " \
--title " Scarf v ${ VERSION } " \
2026-04-16 19:04:27 -07:00
" ${ GH_FLAGS [@] } " \
2026-04-16 20:10:17 -07:00
" $UNIVERSAL_ZIP " \
" $ARM64_ZIP "
2026-04-16 18:42:20 -07:00
2026-04-17 17:24:31 -07:00
if [ [ $DRAFT -eq 1 ] ] ; then
log "Draft mode — main bump commit is local only; push manually with: git push origin main"
2026-04-16 19:04:27 -07:00
fi
2026-04-16 18:42:20 -07:00
2026-04-16 19:04:27 -07:00
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 <language>en</language>) "
else
log " Release v ${ VERSION } complete "
log " Download: $DOWNLOAD_URL "
log " Appcast: $APPCAST_URL "
fi