Capsule ships as a Developer ID–signed, notarized, stapled .app, distributed as a DMG
for humans and a zip for Sparkle auto-updates. The Mac App Store is out of scope (the app is
unsandboxed to drive the container CLI).
- Tagging
vX.Y.Zruns.github/workflows/release.ymloncapsule-native/capsule: build → Developer-ID sign → notarize → staple → package → sign the appcast. - The workflow publishes a GitHub Release with
Capsule-<v>.dmg,Capsule-<v>.zip, andappcast.xml. The appcast's<enclosure>URLs point at that release's own asset download URLs (via--download-url-prefix, set from the tag). - A Sync appcast workflow in the Pages repo
capsule-native/capsule-native.github.iopulls the latest release'sappcast.xmland commits it, republishing the feed at https://capsule-native.github.io/appcast.xml — the app'sSUFeedURL. It runs on a schedule (~15 min) and on demand (Actions ▸ Sync appcast ▸ Run workflow) for an instant publish right after a release. - The release job also bumps the Homebrew cask: it recomputes the DMG's SHA-256, renders
Casks/capsule.rbviaupdate-homebrew-cask.sh, and pushes it tocapsule-native/homebrew-tapso thatbrew install --cask capsule-native/tap/capsuleserves the new version. Gated on theHOMEBREW_TAP_TOKENsecret (a fine-grained PAT with Contents: read+write on the tap); absent on forks, it's skipped with a warning.auto_updates truemeans existing installs are updated in place by Sparkle regardless — the bump keeps fresh installs current.
The code repo stays public so Sparkle can download the update zip from the release assets without authentication.
Signed, notarized releases require Developer-ID + notarization credentials held by the
maintainer, plus a Sparkle EdDSA key whose public half is baked into App/Info.plist
(SUPublicEDKey). These are configured as repository secrets and their setup is intentionally
not documented in this public repo. When the signing secrets are absent (a fork, or a PR from
one), the workflow still builds and validates the whole pipeline but skips
notarization/publishing — it never fails for want of credentials, so contributors can run it.
Pipeline behaviour worth knowing:
generate_appcastwritessparkle:edSignatureonly when the app's embeddedSUPublicEDKeymatches the signing key's public key — otherwise it emits an unsigned enclosure with no error, and every client silently rejects the update. Keep the Info.plist public key and the signing key in lockstep.
make release # full pipeline (or: Scripts/release/release.sh)
make release-dry # print the plan without signing anything (no creds needed)Individual steps (each accepts --dry-run):
| Step | Script | Make target | Does |
|---|---|---|---|
| 1 | build.sh |
make archive |
XcodeGen + xcodebuild archive (Release) → dist/Capsule.xcarchive |
| 2 | export.sh |
make export |
xcodebuild -exportArchive (Developer ID) → signed dist/Capsule.app (inside-out, incl. Sparkle; Hardened Runtime) |
| 3 | notarize.sh |
make notarize |
zip + notarytool submit --wait + stapler staple |
| 4 | package.sh |
make package |
dist/Capsule-<v>.zip (Sparkle) + dist/Capsule-<v>.dmg |
| 5 | appcast.sh |
make appcast |
Sparkle generate_appcast signs the zip (in an isolated dir — the .dmg is kept out so Sparkle doesn't reject the same-version pair) + writes appcast.xml (with RELEASE_DOWNLOAD_URL_PREFIX), then fails if any enclosure is unsigned |
| Var | Required | Meaning |
|---|---|---|
TEAM_ID |
signed runs | Apple Developer Team ID |
NOTARY_PROFILE |
signed runs | notarytool keychain profile name |
RELEASE_DOWNLOAD_URL_PREFIX |
CI-set | prepended to the appcast's enclosure filenames — the tag's release-download URL. Empty → filenames stay relative to the appcast. |
SPARKLE_BIN |
optional | dir containing generate_appcast / generate_keys. If unset, appcast.sh searches .build/artifacts, DerivedData/SourcePackages/artifacts, and the global Xcode DerivedData (where xcodebuild archive resolves Sparkle), then PATH. |
SPARKLE_ED_KEY_FILE |
optional | private-key file; else the login keychain |
SPARKLE_ACCOUNT |
optional | keychain account for the private key when SPARKLE_ED_KEY_FILE is unset (this repo's key was generated under capsule-native; without it a local run signs with the wrong/default account → unsigned enclosures) |
DIST_DIR |
optional | output dir (default dist/) |
- Bump
CFBundleShortVersionStringandCFBundleVersioninApp/Info.plist; commit + push. git tag vX.Y.Z && git push origin vX.Y.Z. The tagvX.Y.Zmust match the Info.plist version — the workflow's Verify tag matches app version step fails the build otherwise (no morev1.2.3tags that packageCapsule-0.1.0.zip).- The workflow builds, signs, notarizes, and publishes the Release. The Pages repo picks up the new appcast automatically within ~15 min, or immediately if you run Sync appcast there.
- Version comes from the app, not the tag. Artifact names and the Sparkle appcast use
CFBundleShortVersionStringfromApp/Info.plist; the tag guard just enforces they agree. - Unsigned appcasts can't ship. If the signing key's public half doesn't match the app's
SUPublicEDKey,generate_appcastonly warns and writes an enclosure with nosparkle:edSignature(every client then rejects the update).appcast.shnow fails the build unless every enclosure is signed, so this can't slip out silently. - Sparkle key secret is encoding-agnostic.
release.ymlacceptsSPARKLE_ED_PRIVATE_KEYwhether it was stored as Sparkle's raw base64 key string or base64-encoded once more, and writes the correct--ed-key-fileeither way. generate_appcastdiscovery on CI. The clean runner has no.build/; the tool is resolved into DerivedData by thexcodebuild archivestep (and aswift package resolvestep warms.build/artifacts), andappcast.shsearches both — so the appcast step no longer dies for want of the binary.- Notary password on argv.
notarize.sh/release.ymlpass the app-specific password tonotarytool store-credentials --password(the API has no stdin form). It's masked and never echoed; on the ephemeral, single-tenantmacos-26runner nothing else can read the process table. Don't port this workflow to a shared/self-hosted runner without switching to a notary API key.
xcodebuild -exportArchive signs embedded code inside-out — including Sparkle's nested
Autoupdate, Updater.app, and XPC services — with the Hardened Runtime and a secure
timestamp, which is what the notary service demands. codesign --deep is discouraged by Apple
and easy to get subtly wrong for a framework with nested helpers.