macos-app-releases
Ship native SwiftPM macOS apps outside the Mac App Store, with Developer ID signing, notarization, GitHub Releases and Sparkle 2 updates. Covers DMG, zip and SHA256SUMS assets, an EdDSA-signed appcast
SKILL.mdplugins/nativeapps/skills/macos-app-releases/SKILL.md
GitHubReleasing macOS apps outside the App Store
This pipeline has shipped several SwiftPM apps, sandboxed and not, some with app extensions. make bump V=patch PUSH=1
tags a version; a GitHub Actions run tests, signs with Developer ID, notarizes and staples, and publishes a GitHub
Release with the DMG, zip, SHA256SUMS and a signed Sparkle appcast.xml that installed copies read through
releases/latest. Without CI secrets, the same scripts publish from the Mac. Project skeleton, variants and
entitlements basics are in the macos-app-development skill.
Rules for agents
- Never read, print, copy, export or upload secrets: the .p12 and its password, the app-specific password, the Sparkle
private key, secret values. The user runs
gh secret set,generate_keys -xand Keychain exports; you give them the exact commands. - Run
generate_keys(it creates or reads a Keychain key) only when the user asks for the key. - Notarization uploads the build to Apple: run
make build-notarizedor a credentialedmake releaseonly when the user asks for a release or a notarized build. - Tag, push a tag or create a GitHub release only when the user asks.
make bumpwithoutPUSH=1tags locally. - Never install over, launch or update the user's installed copy to test; check builds in
build/.
Placeholders and templates
| Placeholder | Example | Meaning |
|---|---|---|
<App> |
Acme |
SwiftPM product, executable and .app name; <App>-X.Y.Z.dmg |
<app> |
acme |
Sparkle Keychain account (lowercase) |
<bundle id> |
com.example.acme |
Release bundle id; Dev and Test builds append .dev and .test |
<owner>/<repo> |
example/acme |
Public GitHub repository |
<profile> |
example-notary |
notarytool Keychain profile, shared by the team's apps |
<TEAMID> |
ABCDE12345 |
Apple team id, in parentheses in the Developer ID identity's name |
| Template | Copy to | Does |
|---|---|---|
| build-app.sh | scripts/ |
Assembles the .app, embeds and thins Sparkle, signs nested code |
| release.sh | scripts/ |
App, zip, DMG, SHA256SUMS; notarizes and staples, or ad-hoc + what's missing |
| appcast.sh | scripts/ |
Downloads the live feed, adds and signs the new item with its release notes |
| bump-version.sh | scripts/ |
VERSION, CHANGELOG.md section, commit, vX.Y.Z tag, optional atomic push |
| changelog.sh | scripts/ |
CHANGELOG.md sections from Conventional Commits; prints a version's notes |
| ci-select-xcode.sh | scripts/ |
Picks the newest Xcode 26+ on the runner |
| release.yml, ci.yml | .github/workflows/ |
Tag-driven release; tests and a release build on PRs and main |
| release.mk | Makefile |
app, release, build-notarized, install-notarized, appcast, bump |
| AppUpdater.swift | app sources | Sparkle controller and Check for Updates… (SwiftUI or AppKit) |
| releasing.md | docs/ |
Maintainer doc: both routes, feed, key, secrets |
1. Decide first
| Choice | Recommendation | Why |
|---|---|---|
| Store | Developer ID (this skill). App Store needs an Xcode project, the sandbox and Apple's updates | Sparkle isn't allowed in the store; Developer ID keeps features the sandbox blocks |
| Hosting | Public repo: GitHub Releases, feed https://github.com/<owner>/<repo>/releases/latest/download/appcast.xml |
No bucket or extra credentials; latest never points at a pre-release |
| Private repo | Its assets need auth: host files and feed on a bucket behind your domain, uploaded by CI | Sparkle and browsers can't authenticate (R2's r2.dev URLs are rate-limited: use a custom domain) |
| Feed URL | Final before the first shipped build | It's baked into every installed copy |
| Architectures | Apple silicon only (ARCH=arm64, Sparkle thinned to match) |
Smaller and simpler; Intel needs a universal build |
| Versions | ./VERSION is CFBundleShortVersionString; the build number is the commit count |
Only grows; Sparkle compares CFBundleVersion |
| Pre-releases | vX.Y.Z-beta.N tags publish a GitHub pre-release without a feed |
Testers download it; installed copies never update to it |
| Sandbox | Optional with Developer ID; if sandboxed, Sparkle needs its installer service (§3) | Decide before features (see macos-app-development) |
Record these in a decision record (context, decision, consequences).
2. Once per developer (the user)
- Developer ID Application certificate (paid Apple Developer Program; Apple limits who on the team can create it):
Xcode ▸ Settings ▸ Accounts ▸ Manage Certificates ▸ + ▸ Developer ID Application. Check it and read the team id:
security find-identity -v -p codesigning→"Developer ID Application: Name (TEAMID)". - App-specific password for the notarizing Apple Account: account.apple.com ▸ Sign-In and Security ▸ App-Specific Passwords.
- notarytool profile in the login Keychain, one for all the team's apps:
xcrun notarytool store-credentials <profile> --apple-id <apple id> --team-id <TEAMID>(prompts for the password). Check:xcrun notarytool history --keychain-profile <profile>.
3. Once per app
Sparkle 2 through SwiftPM. In Package.swift,
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.10.0"); the app target gets
.product(name: "Sparkle", package: "Sparkle") and
linkerSettings: [.unsafeFlags(["-Xlinker", "-rpath", "-Xlinker", "@executable_path/../Frameworks"])].
build-app.sh copies the framework with ditto, thins it to ARCH, strips the absolute
rpaths SwiftPM adds, and signs innermost first with --force --options runtime --timestamp (ad-hoc without either when
there's no identity):
Sparkle.framework/Versions/B/XPCServices/Installer.xpc, thenDownloader.xpcwith--preserve-metadata=entitlementsVersions/B/Autoupdate,Versions/B/Updater.app, thenSparkle.framework- App extensions (sandboxed, each with its own entitlements) and helpers
- The app, with its entitlements: hardened runtime, only proven needs, each with an XML comment saying why
Info.plist: SUFeedURL (above) and SUEnableAutomaticChecks true (skips Sparkle's "check automatically?"
question). build-app.sh adds SUPublicEDKey from Resources/SparklePublicKey.txt to release builds and deletes the
feed keys from Dev and Test builds, so they never update over the installed release. A sandboxed app also needs
SUEnableInstallerLauncherService true, com.apple.security.network.client (or SUEnableDownloaderService) and a
com.apple.security.temporary-exception.mach-lookup.global-name array with $(PRODUCT_BUNDLE_IDENTIFIER)-spks and
$(PRODUCT_BUNDLE_IDENTIFIER)-spki; build-app.sh fills in each variant's bundle id.
EdDSA keys (the user runs these):
swift package resolve
.build/artifacts/sparkle/Sparkle/bin/generate_keys --account <app> # creates or reuses; prints the public key
.build/artifacts/sparkle/Sparkle/bin/generate_keys --account <app> -p # prints it again
Commit the public key as Resources/SparklePublicKey.txt. Back up the private key outside the repo (export with -x,
store it in a password manager): installed copies only accept updates signed with it. One account per app keeps keys
apart; Sparkle also allows one key for all apps.
Updater UI: AppUpdater.swift. SPUStandardUpdaterController(startingUpdater: false…)
is nil without a feed and key, starts a second after launch, and backs Check for Updates… right after About in the app
menu (and in a menu bar panel, if the app has one), disabled when nil.
4. Add the release tooling
Copy the templates, replace the placeholders, chmod +x scripts/*.sh, put 0.1.0 in VERSION, and confirm nothing is
left: grep -rnE '<(App|app|bundle id|owner|repo|profile)>' scripts .github Makefile docs (drop the templates'
"Template from…" notes, which name them too). Then make release builds ad-hoc and lists what's missing, which proves
the pipeline before any credential is involved. Adjust the Test steps in both workflows to the app's packages.
Release notes come from the commits. make bump adds the release's CHANGELOG.md section (changelog.sh): the
Conventional Commits since the previous release (a stable one counts from the previous stable tag, so it includes its
betas), as breaking changes (feat!:, BREAKING CHANGE:), features, fixes and performance; other types stay out. That
section is the GitHub release's body (release.yml) and, written next to the zip as <App>-X.Y.Z.md, the update's
notes in appcast.xml, which Sparkle's update window renders (sparkle:format="markdown"). So the project's commit
subjects must be Conventional Commits that read well in a list. Backfill releases made before this with
scripts/changelog.sh section X.Y.Z <previous tag> <tag>. To reword notes, bump without PUSH, edit, amend,
git tag -fa.
5. GitHub setup (the user runs these)
Restrict the
releaseenvironment tov*tags:REPO=<owner>/<repo> gh api -X PUT "repos/$REPO/environments/release" --input - <<'JSON' {"deployment_branch_policy": {"protected_branches": false, "custom_branch_policies": true}} JSON gh api -X POST "repos/$REPO/environments/release/deployment-branch-policies" -f name='v*' -f type=tag gh api "repos/$REPO/environments/release/deployment-branch-policies" --jq '.branch_policies[] | "\(.type) \(.name)"'Export the identity: Keychain Access ▸ login ▸ My Certificates ▸ "Developer ID Application: …" (its disclosure triangle shows the private key; without it, the .p12 can't sign) ▸ File ▸ Export Items… ▸
.p12with a strong password.Set the six secrets. Without
--body,ghprompts for the value or reads stdin:base64 -i developer-id.p12 | gh secret set DEVELOPER_ID_P12 --env release -R "$REPO" gh secret set DEVELOPER_ID_P12_PASSWORD --env release -R "$REPO" gh secret set APPLE_ID --env release -R "$REPO" gh secret set APPLE_APP_PASSWORD --env release -R "$REPO" gh secret set APPLE_TEAM_ID --env release -R "$REPO" .build/artifacts/sparkle/Sparkle/bin/generate_keys --account <app> -x sparkle.key gh secret set SPARKLE_PRIVATE_KEY --env release -R "$REPO" < sparkle.key rm sparkle.key developer-id.p12 # once both are backed up outside the repo gh secret list --env release -R "$REPO" # the six names; values are never shown
GitHub personal accounts have no account-wide Actions secrets: each repository needs its own copy (the same values work
for every app of the team). One app-specific password can serve every app; one per repository (named after it) lets the
user revoke one without breaking the others. APPLE_TEAM_ID isn't secret (it's in every signature): --body <TEAMID>.
Every secret is optional: without them the workflow publishes an ad-hoc release without a feed and says so in the notes.
6. First release and verification
make bump V=0.1.0 PUSH=1 # first release: tags the current VERSION; later: make bump V=patch PUSH=1
gh run watch -R <owner>/<repo>
gh release view v0.1.0 -R <owner>/<repo> --json assets --jq '.assets[].name' # dmg, zip, SHA256SUMS, appcast.xml
On the downloaded assets (or build/release for a local build):
shasum -a 256 -c SHA256SUMS
spctl -a -vv <App>.app # accepted, source=Notarized Developer ID
codesign --verify --deep --strict --verbose=2 <App>.app
xcrun stapler validate <App>.app && xcrun stapler validate <App>-X.Y.Z.dmg
curl -sL https://github.com/<owner>/<repo>/releases/latest/download/appcast.xml | grep -E 'shortVersionString|edSignature'
The feed's new item needs the version, an sparkle:edSignature and a url into releases/download/vX.Y.Z/. The real
proof needs two stable releases: the user installs the older one in /Applications, chooses Check for Updates…, gets
offered the newer one, and it installs and relaunches in place. Record that check; it covers feed, signature, framework
signing and (sandboxed) installer services at once. Pre-release tags publish without a feed, so test updates with stable
versions.
7. Publishing from the Mac (no CI secrets)
make bump V=X.Y.Z # commit and tag locally; nothing is pushed
make build-notarized # Keychain identity + <profile>; stops without an identity
make appcast # Keychain Sparkle key, account <app>
git push --atomic origin main vX.Y.Z
gh release create vX.Y.Z build/release/<App>-X.Y.Z.dmg build/release/<App>-X.Y.Z.zip \
build/release/SHA256SUMS build/appcast/appcast.xml --title vX.Y.Z --verify-tag \
--notes-file <(scripts/changelog.sh notes X.Y.Z)
The tag push still starts the workflow, which tests and builds for minutes, then finds the release and leaves its files
alone. Create the release right after pushing; if the workflow wins the race, it publishes its own (ad-hoc, without a
feed) and the user deletes that release (gh release delete vX.Y.Z keeps the tag) and creates theirs.
8. Pitfalls
- Runners aren't your Mac. A debug-build timing bound failed one first tag run; a UI test failed on another because
runners show legacy scroll bars. Keep timing asserts loose and UI tests independent of system settings. If a run
wedges at start with no test output, wrap
swift testin a watchdog that samples, kills and retries. - Release builds compile differently. A Swift 6 concurrency error appeared only with
-c releaseon the runner's newer Xcode:ci.ymlbuilds the release configuration too. - A failed tag run publishes nothing. Fix on main and release the next patch; moving a pushed tag is the user's call.
- Never overwrite a published release.
gh release upload --clobberonce replaced notarized files from the Mac with an ad-hoc CI build, which also breaks the feed's signatures. The workflow leaves existing releases alone. - Every stable release needs
appcast.xml. The feed followsreleases/latest, so a stable release without it breaks update checks for everyone until the next one. Without the Sparkle key in CI, publish from the Mac. - Feed history comes from the previous release.
appcast.shdownloads it withcurl -L(GitHub redirectslatest; without-Lyou sign a redirect page). It reuses an existingbuild/appcast/appcast.xml: delete that folder when CI published the last release. - A wrong key fails silently.
generate_appcastonly warns and writes the item unsigned when the key doesn't matchSUPublicEDKey;appcast.shfails instead. --generate-notesis nearly empty for a project that commits straight to main: GitHub lists merged pull requests, so the body is just a compare link. TheCHANGELOG.mdsection is the notes;--generate-notesis only the workflow's fallback when a version has none.- Shallow clones break updates. Without
fetch-depth: 0the build number is 1 and Sparkle sees an older build. - The tag must equal
VERSION: tag withmake bumponly. - CI keychain: a temporary keychain with
set-key-partition-list(or codesign waits on an invisible prompt), added to the search list, notary credentials stored in it with--keychain, deleted at the end. - Notarize the zip, ship the stapled app. notarytool takes a zip or DMG; staple the app, zip it again, and sign, notarize and staple the DMG on its own.
- Signing: never
codesign --deepto sign (one set of entitlements for everything); sign each nested piece. An ad-hoc build with the hardened runtime can't load Sparkle under library validation (no team id): leave the runtime to real identities, asbuild-app.shdoes, or addcom.apple.security.cs.disable-library-validationto that build. - Sparkle at launch: its first-run prompt during launch kept a window from opening; start it a second later.
- Absolute rpaths from SwiftPM point at the checkout's
.build: strip them so the bundle is self-contained. - Runner labels: use a real image (
macos-26) plusci-select-xcode.sh; a label that matches no runner never starts.
Checklist
- Decisions recorded: Developer ID, hosting, feed URL, architectures, sandbox.
-
security find-identityshows the identity;notarytool historyworks with the profile. - Sparkle embedded and signed nested-first;
codesign --verify --deep --strictpasses; Dev/Test have no feed. -
Resources/SparklePublicKey.txtcommitted; the private key backed up outside the repo. - Templates copied, no placeholders left, ad-hoc
make releaselists what's missing. - Commits follow Conventional Commits;
CHANGELOG.mdstarts with the first release (or is backfilled). -
releaseenvironment limited tov*tags with six secrets, or the Mac route documented. - First release: four assets,
spctlandstaplerpass, signed appcast item. - Second release: an installed older build updates in place through Check for Updates….