Build Mac AppDocsMenu

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:

CertificateSigns forGatekeeper's answer on someone else's Mac
Apple Developmentyour own registered devicesrejected
Developer ID Applicationdirect distribution to anybodyaccepted, 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

  1. 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.
  2. Xcode → Settings → Accounts → your Apple ID → Manage Certificates → + → Developer ID Application. Apple allows a limited number of these per account; do not create spares.
  3. 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.
  4. 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 — see mac/AGENTS.md. scripts/new-app.sh --team does it; the preflight's team check 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:

  1. gh release create v1.0.0 … with both DMGs.
  2. Commit site/public/appcast.xml and site/lib/changelog.ts together, 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.

ScriptDoesSafe to re-run
release-preflight.sh <version> <build>asks every question, changes nothingalways
build-dmg.sh [--unsigned] <version> <build>universal build, sign, DMGyes, overwrites
smoke-launch.sh <app>launch each slice, check it survivesalways
notarize.sh <dmg>submit, wait, staple, verifyyes
sign-appcast.sh <version> <build> <dmg>EdDSA signature, append <item>no — it appends
release.sh <version> <build>all of the above, in ordersee 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's releases/latest/download/ URL. site/lib/download.ts builds that URL from ${SITE.name}.dmg and 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:

  1. 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.)
  2. The build number did not go up. Sparkle compares sparkle:version, never the marketing version: mount the DMG and read CFBundleVersion. The workflow derives the build from the release's position in CHANGELOG, so a tag push can no longer build everything as 1.

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 tag v and nothing else.
  • Deploying the site. The appcast reaches every installed copy of the app. That deserves a person pressing the button.
  • Version bumping. project.yml keeps MARKETING_VERSION at a developer default and the release passes the real numbers to xcodebuild as 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-apple asks Apple). Copy mac/scripts/release-secrets.env.example to .env.release-secrets beside it, fill it in, and run ./set-release-secrets.sh --check-apple --shred.

    SecretWhat 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:

    1. commit the changelog row, if it is not in yet;
    2. publish the draft release — this creates the tag and makes both DMGs downloadable;
    3. commit site/public/appcast.xml from 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 guard job): 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.