Files
scarf/scripts/release.sh
T
Alan Wizemann 868e61979e chore: release script supports --draft + RELEASE_NOTES.md
Drafts skip the appcast push and main tag, so a draft release won't
show up in users' Sparkle update feed and v1.6.0 stays "latest" until
explicitly promoted. The signed appcast entry is saved to the release
dir for later manual promotion.

Also adds release notes file convention: releases/v<VERSION>/RELEASE_NOTES.md
is auto-included in the version-bump commit and used as the GitHub
release body.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 19:04:27 -07:00

268 lines
10 KiB
Bash
Executable File

#!/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<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.
#
# 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
# ---------- 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; }
# ---------- 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 <<EOF
<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>
<enclosure url="${DOWNLOAD_URL}"
sparkle:edSignature="${ED_SIGNATURE}"
length="${FILE_LENGTH}"
type="application/octet-stream" />
</item>
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 <language>en</language> line
python3 - "$APPCAST_ITEM" <<'PY'
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
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 <language>en</language>)"
else
log "Release v${VERSION} complete"
log " Download: $DOWNLOAD_URL"
log " Appcast: $APPCAST_URL"
fi