Files
scarf/scripts/release.sh
T
Alan Wizemann cd5bb32a21 release: prep v2.7.0 — consolidated notes + in-app Sparkle release notes
Rolls up everything since v2.6.5 (36 commits across remote-perf,
project wizard, dashboard widgets, OAuth resilience, ScarfMon
instrumentation, and the v2.7 skeleton-then-hydrate redesign) into
a single 2.7.0 release.

* releases/v2.7.0/RELEASE_NOTES.md — full consolidated notes,
  reorganized around the throughline (slow-remote performance) with
  five thematic sections: skeleton-then-hydrate loaders, SSH
  cancellation, project wizard + Keychain cron secrets, dashboard
  widgets, OAuth resilience, and ScarfMon. Replaces the previously-
  drafted dashboard-only v2.7.0 stub and the separate v2.8 wizard
  stub (both unreleased).
* releases/v2.8/ — deleted; folded into v2.7.
* README.md — "What's New in 2.6" → "What's New in 2.7" with the
  five-section summary linking out to the full notes.

* tools/render-release-notes.py — stdlib-only Markdown → HTML
  renderer covering the subset of GitHub-flavored markdown that
  release notes use (## / ### headings, paragraphs, ul lists,
  fenced code, inline code/bold/italic/links, hr). Output includes
  a small <style> block tuned for Sparkle's update alert WebKit
  view (light + dark variants via prefers-color-scheme).
* scripts/release.sh — render the active RELEASE_NOTES.md and
  inject the result as <description><![CDATA[...]]></description>
  on the appcast item. Sparkle's standard updater renders this in
  the in-app update sheet so users see release-specific "what's
  new" alongside the version number, not just the bare version.
  Falls back to a "see GitHub release page" placeholder when the
  notes file is missing.

User runs ./scripts/release.sh 2.7.0 to ship.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:31:27 +02:00

335 lines
14 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"
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<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
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 <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"
mkdir -p "$variant_dir"
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"
# 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"
log "[$label] Zip for notarization"
ditto -c -k --keepParent "$app_path" "$notarize_zip"
log "[$label] Submit to notarytool (blocking)"
xcrun notarytool submit "$notarize_zip" \
--keychain-profile "$NOTARY_PROFILE" \
--wait \
--timeout 30m
log "[$label] Staple + validate"
xcrun stapler staple "$app_path"
xcrun stapler validate "$app_path"
spctl --assess --type execute --verbose "$app_path"
log "[$label] Package $(basename "$out_zip")"
ditto -c -k --keepParent "$app_path" "$out_zip"
# 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"
}
UNIVERSAL_ZIP="$RELEASE_DIR/Scarf-v${VERSION}-Universal.zip"
ARM64_ZIP="$RELEASE_DIR/Scarf-v${VERSION}-ARM64.zip"
build_variant "Universal" "arm64 x86_64" "$UNIVERSAL_ZIP"
build_variant "ARM64" "arm64" "$ARM64_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")"
# 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
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>
<description><![CDATA[
${RELEASE_NOTES_HTML}
]]></description>
<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
# ---------- 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
# ---------- 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" \
"$ARM64_ZIP"
if [[ $DRAFT -eq 1 ]]; then
log "Draft mode — main 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