Ship
Releasing
The operator's runbook for getting a signed, notarized, auto-updating build to customers — by hand, or through GitHub Actions, which runs the same scripts.
Read the first section before you plan a release date: the thing that unblocks a first release takes a day or two of Apple's time, not yours.
Before you have any of it, rehearse the disk image:
mac/scripts/build-dmg.sh --unsigned 1.0.0 1
It builds the universal app and the DMG exactly as a release does, unsigned, so you can open it and check the window. It cannot be notarized and no other Mac will open it.
The signing blocker
There are two kinds of code-signing certificate, and they do different jobs:
| Certificate | Signs for | Gatekeeper's answer on someone else's Mac |
|---|---|---|
| Apple Development | your own registered devices | rejected |
| Developer ID Application | direct distribution to anybody | accepted, once notarized |
An app signed with Apple Development runs perfectly on the machine that built
it, which is exactly what makes this trap expensive: everything looks finished
until a stranger downloads it and is told the app is damaged and should be moved
to the Bin. There is no build setting, entitlement or spctl invocation that
changes this. Apple issues Developer ID certificates only to accounts enrolled
in the Apple Developer Program.
security find-identity -v -p codesigning lists what your keychain holds.
What unblocks it
- Enrol at https://developer.apple.com/programs/. This costs an annual fee and Apple's review of a new account has taken anything from an hour to a week. An individual enrolment needs a government ID; an organisation enrolment needs a D-U-N-S number, which is its own multi-day errand — decide which one you want before starting, because switching afterwards means re-enrolling.
- Xcode → Settings → Accounts → your Apple ID → Manage Certificates → + → Developer ID Application. Apple allows a limited number of these per account; do not create spares.
- Back the certificate up. Keychain Access → export the certificate and its
private key as a
.p12, and put it somewhere that survives this Mac. A lost Developer ID private key cannot be recovered — you revoke and reissue, and every copy of the app signed with the old one keeps working only because of the trusted timestamp in its signature. - Put your Team ID in
mac/project.yml(DEVELOPMENT_TEAM). With it empty the app is signed ad hoc, and an ad-hoc Release build crashes at launch — seemac/AGENTS.md.scripts/new-app.sh --teamdoes it; the preflight'steamcheck prints the ID it expects.
Until step 2 is done, mac/scripts/release-preflight.sh fails on identity in
about a second and nothing else runs. That is the intended behaviour, and it is
the reason the preflight exists.
One-time setup
Six things, and the preflight checks all six by name. Do them in this order — each depends on the one before.
1. The Developer ID certificate
Above.
2. A notarytool keychain profile
xcrun notarytool store-credentials "<product>-notary" \
--apple-id <your Apple ID> \
--team-id <your 10-character Team ID> \
--password <an app-specific password>
The profile name the scripts expect is the product name, lowercased with spaces
turned into dashes, plus -notary; NOTARY_PROFILE overrides it. The password
is an app-specific password generated at https://appleid.apple.com, not
your Apple ID password. store-credentials accepts the wrong one without
complaint and the failure appears at the first submission, which is the
expensive place to discover it.
3. Sparkle's EdDSA key pair
Run Sparkle's generate_keys once, ever, under this product's own account
(the product's name, lower-cased with dashes — mac/scripts/release-lib.sh
sparkle_account):
"$(find mac/build ~/Library/Developer/Xcode/DerivedData -name generate_keys -path '*artifacts*' | head -1)" --account your-app
It writes the private key into your login keychain and prints the public key.
Always pass --account. Without it Sparkle uses one global key per macOS
user, and a second app released from the same Mac is silently signed with the
first app's key — which its SUPublicEDKey does not match, so every installed
copy rejects the update. An existing product already signing with the global
key keeps it with SPARKLE_ACCOUNT=ed25519.
This key is the single most irreplaceable thing in the project. It is what proves an update came from you. Lose it and no installed copy of the app can ever be updated again — by you or by anyone — and the only remedy is asking every customer to download a new build by hand. Back up the keychain item, to somewhere that is not this Mac, today.
4. SUPublicEDKey in the app bundle
Paste the public key generate_keys printed into
mac/App/Resources/Info.plist:
<key>SUPublicEDKey</key>
<string>…the base64 public key…</string>
There is no delegate hook for this one, which is why it lives in the plist while
the feed URL does not — mac/App/Updates/UpdateController.swift explains that
split. Until it is there the app does not start its updater at all and shows no
updates row; with a wrong key, Sparkle downloads an update and then refuses to
install it, which reads to a customer as a corrupt download.
The preflight compares this value against the keychain's key pair and fails on a mismatch, because "present" and "correct" are different facts.
5. The Sparkle package
Sparkle is a Swift Package, declared in mac/project.yml. UpdateController
is guarded with #if canImport(Sparkle), so a build without the package still
builds and runs — with no updater. A source project shipped exactly that. The
preflight's welds section fails a release if the package is not declared.
6. GITHUB_RELEASE_REPO
export GITHUB_RELEASE_REPO=owner/repo
The same variable site/lib/download.ts reads on the website, so configuring the
download once configures both sides. The scripts fall back to the origin
remote if it is unset; the release workflow sets it to the repository it runs in.
Doing a release
Before anything
Add the row to CHANGELOG in site/lib/changelog.ts, with a hand-written ISO
date. Root AGENTS.md §6: the row ships in the same commit as the tag or
before it. This is not a formality here — the release pipeline reads that row.
The appcast's pubDate is the date you typed, and sign-appcast.sh refuses to
run without it.
Then:
cd mac
./scripts/release.sh 1.0.0 1
Two numbers: the marketing version (1.0.0, what a customer sees) and the build
number (1, an integer that must increase on every release). Sparkle compares
the build number — a marketing version that goes up while the build number
does not produces a published release that every installed copy is told it
already has.
release.sh runs the preflight, builds, launches the signed app on each
architecture for a few seconds, notarizes, makes the stable copy, and writes the
appcast. It stops there and prints the last two commands, which are
the irreversible ones and are meant to be run by a person:
gh release create v1.0.0 …with both DMGs.- Commit
site/public/appcast.xmlandsite/lib/changelog.tstogether, and deploy.
In that order. The feed points at the binary; publishing the feed first tells every installed copy to download a file that is not there.
The individual steps
Each script runs on its own and re-checks what it depends on, so a release that failed partway through can be resumed rather than restarted.
| Script | Does | Safe to re-run |
|---|---|---|
release-preflight.sh <version> <build> | asks every question, changes nothing | always |
build-dmg.sh [--unsigned] <version> <build> | universal build, sign, DMG | yes, overwrites |
smoke-launch.sh <app> | launch each slice, check it survives | always |
notarize.sh <dmg> | submit, wait, staple, verify | yes |
sign-appcast.sh <version> <build> <dmg> | EdDSA signature, append <item> | no — it appends |
release.sh <version> <build> | all of the above, in order | see above |
sign-appcast.sh is the one to be careful with. It appends, and the preflight's
appcast section refuses a version that is already in the feed — so a second
run fails loudly rather than adding a duplicate item, which is the behaviour you
want but is worth knowing before you try it.
Two DMGs, and why
mac/build/release/ ends up with both:
<Product>-1.0.0.dmg(spaces become dots — GitHub renames them on upload, so the files are named that way first) — immutable, per version. The appcast points here. An enclosure URL must never move: Sparkle records an EdDSA signature for specific bytes, and a URL that later resolves to a different release is reported to the user as a tampered download.<Product>.dmg— the stable name. The website points here, through GitHub'sreleases/latest/download/URL.site/lib/download.tsbuilds that URL from${SITE.name}.dmgand checks it; if the asset is missing or misnamed there is no download at all, with nothing logged that anybody watches.
Upload both. The stable copy is made after stapling so that it carries the notarization ticket too.
When it goes wrong
Notarization rejected. notarize.sh fetches Apple's log for you and prints
it. In practice it is always one of three things: a nested binary signed without
the hardened runtime, a signature with no --timestamp, or something signed
with a certificate that is not Developer ID. All three mean something re-signed
the bundle after build-dmg.sh did.
Gatekeeper still rejects the stapled DMG. Do not ship it. Run
spctl --assess --type open --context context:primary-signature -vv <dmg> for
the detail. This is rare and usually means the disk image was modified after
stapling — copying it with a tool that rewrites extended attributes will do it.
Installed copies say "You're up to date" after a release. Two causes, both seen in a source project on one release, and both checkable in a minute:
- The feed has no item for the release.
curl -s https://<domain>/appcast.xml | sed '/<!--/,/-->/d'must show an<item>for it — the release job leaves the item in its artifact and it is not live until it is committed and the site deployed. (The shape documented in the file is inside a comment and does not count; grepping the raw file will mislead you.) - The build number did not go up. Sparkle compares
sparkle:version, never the marketing version: mount the DMG and readCFBundleVersion. The workflow derives the build from the release's position inCHANGELOG, so a tag push can no longer build everything as1.
And check the release has both DMGs attached before announcing anything:
gh release view v<version> --json assets. A release with none makes the
site's download resolve to nothing.
Publishing a draft starts another run. Publishing is what creates the tag,
and a tag push runs the release workflow. The guard job now makes that run do
nothing when the release is already published with DMGs — before it existed, the
second run replaced the published files with a fresh build, and the appcast item
signed over the first build became invalid. Take the appcast item from the run
whose DMGs are on the release, and before committing it, verify its
edSignature against the served bytes and the app's SUPublicEDKey.
You shipped a bad build. Delete the <item> from site/public/appcast.xml and
deploy the site. That stops the update being offered within minutes to everyone
who has not taken it yet. Then publish a fixed version with a higher build
number; Sparkle has no concept of a downgrade, so there is no way to pull back a
copy that already installed. Leave the GitHub release in place — deleting it
breaks the download for anyone mid-install.
The build is not universal. build-dmg.sh checks with lipo and stops. A
stale DerivedData is the usual cause; delete mac/build/release/DerivedData. The
site sells "Apple silicon or Intel", so this check is a promise being kept
rather than a nicety.
The app crashes at launch with "Library not loaded … different Team IDs".
It was signed ad hoc — DEVELOPMENT_TEAM is empty. See the signing blocker,
step 4.
What is deliberately not automated
- Tagging. The git tag is created by
gh release create, from a version you typed twice — once into the changelog, once on the command line. A pipeline that tags from a variable will one day tagvand nothing else. - Deploying the site. The appcast reaches every installed copy of the app. That deserves a person pressing the button.
- Version bumping.
project.ymlkeepsMARKETING_VERSIONat a developer default and the release passes the real numbers toxcodebuildas build settings. So a failed release leaves no half-bumped version behind in a tracked file, and the version of record is the git tag and the changelog row rather than a fourth copy of the same number.
Releasing from GitHub Actions
.github/workflows/release.yml drives the same scripts on a runner.
-
Set the eight secrets once with
mac/scripts/set-release-secrets.sh, which validates each value locally before uploading any (the p12 opens with its password and holds a Developer ID identity; the app-specific password has the right shape;--check-appleasks Apple). Copymac/scripts/release-secrets.env.exampleto.env.release-secretsbeside it, fill it in, and run./set-release-secrets.sh --check-apple --shred.Secret What it is MACOS_CERTIFICATEthe Developer ID .p12, base64 (the script encodes it)MACOS_CERTIFICATE_PWDthe password chosen at export MACOS_DEVELOPER_IDthe identity string, Developer ID Application: Name (TEAMID)KEYCHAIN_PWDthrowaway; generated if left empty NOTARIZATION_APPLE_IDthe Apple ID enrolled in the program NOTARIZATION_PWDan app-specific password from appleid.apple.com NOTARIZATION_TEAM_IDthe ten-character Team ID; taken from the identity if empty SPARKLE_PRIVATE_KEYfrom generate_keys --account your-app -x <file>; lets CI sign the appcast -
Prove the chain before any tag. Actions → Release → Run workflow, with a version that has a changelog row and sign ticked. It builds, signs, notarizes, staples, signs the appcast item and leaves a draft release. Run it as often as it takes; nothing is published.
-
Release: push the tag (
git tag v1.0.0 && git push origin v1.0.0), or publish a dispatch-built draft. Then, in this order:- commit the changelog row, if it is not in yet;
- publish the draft release — this creates the tag and makes both DMGs downloadable;
- commit
site/public/appcast.xmlfrom the run's artifact and deploy the site.
The feed points at a release asset, so committing it before step 2 publishes a feed whose only entry is a 404.
-
A tag push for a release that is already published with DMGs does nothing (the
guardjob): rebuilding would replace the bytes the appcast item was signed over.
Before announcing a release
The checks no script can make:
- On a Mac that has never seen the app, download the DMG from the site, open it, drag the app to Applications and launch it. Gatekeeper must not complain.
- Paste a real licence key into the gate with ⌘V and activate.
- From the previous release, installed, choose Check for Updates… and watch it update to this one.
This page is docs/releasing.md in the repository, copied 2026-09-25.