| name | sparkle-updater |
| description | Sparkle auto-update system for TermQ. Load when working on release workflows, appcast generation, update signing, or debugging update failures. Covers the full update pipeline from signing through delivery. |
Sparkle Updater System
Architecture
The auto-update pipeline has five stages. A failure at ANY stage breaks updates silently:
Build & Sign → ZIP Archive → EdDSA Sign → GitHub Release → Appcast → User Update
CI CI CI CI CI Sparkle
Critical Rules
1. ALWAYS use ditto for ZIP creation, NEVER zip
ditto -c -k --keepParent TermQ.app TermQ-VERSION.zip
zip -r TermQ-VERSION.zip TermQ.app
zip -r -y TermQ-VERSION.zip TermQ.app
Why: Sparkle.framework uses a versioned structure with symlinks (Versions/Current -> B, top-level Sparkle -> Versions/Current/Sparkle, etc.). zip -r follows symlinks and stores full copies, creating 3x duplicates and destroying the framework structure. When Sparkle extracts this ZIP, codesign --verify fails with "bundle format is ambiguous" and the update is rejected.
How to verify: After creating the ZIP, extract it and run:
ls -la TermQ.app/Contents/Frameworks/Sparkle.framework/
codesign --verify --deep --strict --verbose=2 TermQ.app
2. Sign Sparkle components inside-out, NEVER use --deep
for xpc in "$SPARKLE/XPCServices/"*.xpc; do
codesign --force --options runtime --timestamp \
--sign "$IDENTITY" --keychain "$KEYCHAIN" "$xpc"
done
codesign --force --options runtime --timestamp \
--sign "$IDENTITY" --keychain "$KEYCHAIN" "$SPARKLE/Updater.app"
codesign --force --options runtime --timestamp \
--sign "$IDENTITY" --keychain "$KEYCHAIN" "$SPARKLE/Autoupdate"
codesign --force --options runtime --timestamp \
--sign "$IDENTITY" --keychain "$KEYCHAIN" "$SPARKLE"
codesign --force --options runtime --timestamp \
--sign "$IDENTITY" --keychain "$KEYCHAIN" \
--entitlements TermQ.entitlements TermQ.app
Why: --deep overrides each component's own entitlements and breaks the XPC trust chain. The Installer.xpc and Downloader.xpc services need their own signing identity. Using --deep causes "An error occurred while running the updater" at install time.
3. EdDSA signing uses Sparkle's sign_update tool
SIGN_UPDATE=".build/artifacts/sparkle/Sparkle/bin/sign_update"
echo "$SPARKLE_PRIVATE_KEY" | "$SIGN_UPDATE" --ed-key-file - -p "$ZIP"
--ed-key-file - reads the private key from stdin
-p outputs only the raw base64 signature (no XML wrapping)
- The
.sig file must be uploaded as a GitHub Release asset
- The appcast generator fetches it and embeds it in the XML
5. CFBundleVersion and sparkle:version MUST use the same dot-notation format
Sparkle's SUStandardVersionComparator truncates version strings at the first dash (SUStandardVersionComparator.m:114-115). This means 0.7.0-beta.8 and 0.7.0-beta.9 both truncate to 0.7.0 and compare as equal — no update is ever offered between consecutive beta releases.
The single canonical format everywhere:
| Release | Git tag (GitHub) | App version (everywhere else) |
|---|
| beta | v0.7.0-beta.9 | 0.7.0.b9 |
| alpha | v0.7.0-alpha.3 | 0.7.0.a3 |
| rc | v0.7.0-rc.2 | 0.7.0.rc2 |
| stable | v0.7.0 | 0.7.0 |
The conversion is applied in three places (all must stay in sync):
Makefile: SPARKLE_VERSION := $(shell echo "$(VERSION)" | sed 's/-beta\./.b/;s/-alpha\./.a/;s/-rc\./.rc/')
scripts/generate-appcast.sh: sparkle_version() function
.github/workflows/release.yml: inline sed in the "Create app bundle" step
Both CFBundleVersion and CFBundleShortVersionString use this dot-notation format. The git SHA is stored in the custom key TermQBuildSHA (display only — Sparkle never reads custom keys).
If CFBundleVersion contains a git SHA (like 8be83a1), Sparkle's comparator interprets the leading hex digit numerically — 8... > 0.7.0 — so the installed app always appears newer than any appcast entry. No update is ever offered.
4. GitHub release asset URLs require redirect following
curl -sSL "$sig_url"
curl -sS "$sig_url"
Component Map
| Component | Location | Purpose |
|---|
| Release workflow | .github/workflows/release.yml | Build, sign, notarize, create ZIP, publish |
| Appcast workflow | .github/workflows/update-appcast.yml | Generate appcast XML from GitHub Releases API |
| Appcast generator | scripts/generate-appcast.sh | Fetch releases, signatures, generate XML |
| Stable appcast | Docs/appcast.xml | Stable channel feed (GitHub Pages) |
| Beta appcast | Docs/appcast-beta.xml | All releases including pre-releases |
| Info.plist template | Info.plist.template | SUFeedURL, SUPublicEDKey, update settings |
| Feed URL delegate | Sources/TermQ/TermQApp.swift (SparkleUpdaterDelegate) | Runtime feed selection (stable vs beta) |
| Sparkle dependency | Package.swift | sparkle-project/Sparkle from: 2.6.0 |
| sign_update tool | .build/artifacts/sparkle/Sparkle/bin/sign_update | EdDSA signing/verification |
GitHub Secrets
| Secret | Purpose |
|---|
APPLE_CERTIFICATE_BASE64 | Developer ID Application certificate (P12) |
APPLE_CERTIFICATE_PASSWORD | Certificate import password |
APPLE_ID | Apple ID for notarization |
APPLE_TEAM_ID | Apple Team ID |
APPLE_APP_PASSWORD | App-specific password for notarization |
SPARKLE_PRIVATE_KEY | EdDSA private key for update signing |
APPCAST_TOKEN | GitHub PAT for appcast commit to main |
Keys
- Public key (in Info.plist):
SUPublicEDKey — base64-encoded Ed25519 public key (32 bytes)
- Private key (GitHub secret):
SPARKLE_PRIVATE_KEY — used by sign_update to generate EdDSA signatures
- These MUST be a matching pair. Generated once with
generate_keys tool. If regenerated, ALL existing signed releases become unverifiable.
Update Flow (What Sparkle Does)
1. App checks feed URL (eyelock.github.io/TermQ/appcast[-beta].xml)
2. Parses XML, finds newest version > installed version
3. Downloads ZIP from GitHub Releases (follows 302 redirect to CDN)
4. Verifies EdDSA signature: sign(ZIP bytes) matches sparkle:edSignature
5. Extracts ZIP archive
6. Verifies code signature of extracted .app bundle (codesign --verify)
7. Launches Installer.xpc to replace running app
8. Restarts
Failure at step 4 = bad/missing signature. Failure at step 6 = broken archive (symlinks, signing). Failure at step 7 = XPC signing issue.
Verification Checklist
Before declaring a release workflow change complete, verify ALL of these:
- Version format:
plutil -p TermQ.app/Contents/Info.plist | grep -E "CFBundleVersion|CFBundleShort|TermQBuildSHA" — CFBundleVersion and CFBundleShortVersionString must be dot-notation (e.g. 0.7.0.b9), NOT a git SHA; TermQBuildSHA must be the 7-char git SHA
- Appcast format:
grep "sparkle:version" Docs/appcast-beta.xml | head -3 — must show dot-notation versions, NOT dash-notation
- Version detection: Install the app, run another release — Sparkle must detect and offer the update (end-to-end test required after any version format change)
- Build artifact:
codesign --verify --deep --strict --verbose=2 TermQ.app passes
- ZIP archive: Extract the ZIP, then run
codesign --verify --deep --strict on the extracted app
- Framework symlinks:
ls -la TermQ.app/Contents/Frameworks/Sparkle.framework/ shows symlinks (l prefix)
- EdDSA signature:
.zip.sig file exists, contains 88-char base64 string
- Appcast entries:
sparkle:edSignature attribute present and non-empty
- Download URL:
curl -I -L <url> returns 200 with correct content-length
- Signature verification:
sign_update --verify <zip> <signature> passes (requires private key)
Known Pitfalls
| Pitfall | Consequence | Prevention |
|---|
CFBundleVersion set to git SHA | SHA's leading hex digit compares as numerically huge; installed app is always "newer" than appcast — no update ever offered | Use SPARKLE_VERSION (dot-notation) for CFBundleVersion |
Dash in CFBundleVersion or sparkle:version | SUStandardVersionComparator truncates at first dash; 0.7.0-beta.8 == 0.7.0-beta.9 — consecutive betas never update | Use dot-notation: 0.7.0.b9, not 0.7.0-beta.9 |
Using zip -r instead of ditto | Framework symlinks destroyed, codesign fails | Always use ditto -c -k --keepParent |
Using --deep with codesign | XPC trust chain broken, install fails | Sign components individually, inside-out |
curl without -L for GitHub assets | Gets empty body from 302 redirect | Always use curl -sSL |
Missing .zip.sig release asset | Appcast has no signature, Sparkle rejects update | Verify asset list after release |
| Key pair mismatch | All signature verification fails | Never regenerate keys without updating Info.plist |
| Appcast served stale (GitHub Pages cache) | Users see old version | Wait 2-5 min after push, verify with curl -I |
| Pre-release in stable feed | Stable users see beta versions | is_prerelease() check in appcast generator |
echo trailing newline in key pipe | Could corrupt key parsing | sign_update handles this; don't use printf without testing |
Debugging
Sparkle logs: Enable Sparkle debug logging by adding SUEnableAutomaticChecks to UserDefaults and checking Console.app for Sparkle process output.
Verify a release locally:
curl -sSL -o test.zip "https://github.com/eyelock/TermQ/releases/download/vX.Y.Z/TermQ-X.Y.Z.zip"
curl -sSL -o test.zip.sig "https://github.com/eyelock/TermQ/releases/download/vX.Y.Z/TermQ-X.Y.Z.zip.sig"
cat test.zip.sig
ditto -x -k test.zip /tmp/verify/
codesign --verify --deep --strict --verbose=2 /tmp/verify/TermQ.app
ls -la /tmp/verify/TermQ.app/Contents/Frameworks/Sparkle.framework/