For maintainers. Nothing here contains a credential; the values live outside this repository and reach the workflow as repository secrets.
| Value | Why |
|---|---|
| The bundle identifier | A new one makes macOS treat the app as a different one, orphaning every installed copy. |
| The update feed URL | The installed app keeps asking the old URL. Changing it silently ends updates for everyone, with no way to push a fix. |
Both are based on a GitHub handle rather than the product name or a domain, so the app can be renamed and the repository moved without touching either. The feed itself lives in a separate repository served by GitHub Pages; the enclosure URLs inside it point at this repository's release downloads and are rewritten on every release.
- A Developer ID Application certificate in the login keychain. Not Apple Development, which notarization rejects.
- An App Store Connect Team API key with the Developer role. An Individual key does not work
with
notarytool, and the failure only shows up at submission. - The Sparkle update key pair. The public half is compiled into the app; the private half signs the feed.
If the update private key is lost, no installed copy can ever be updated again. A later release cannot fix it, because the installed app only trusts the public key baked into the copy someone already has. Keep it in a password manager as well as in the repository secret.
| Secret | Contents |
|---|---|
DEVELOPER_ID_IDENTITY |
the full identity string, Developer ID Application: Name (TEAMID) |
DEVELOPER_ID_P12 |
that identity exported as a .p12, base64 encoded |
DEVELOPER_ID_P12_PASSWORD |
the password used for that export |
ASC_KEY_ID |
App Store Connect key ID |
ASC_ISSUER_ID |
App Store Connect issuer ID |
ASC_KEY_P8 |
the contents of the .p8 private key |
SPARKLE_PRIVATE_KEY |
the update signing private key |
A missing secret fails the run loudly on its first step. Signing is never skipped quietly, because an unsigned build that reaches a release page is worse than a run that stops.
- Add the version's section to
CHANGELOG.md. The release notes on GitHub and the notes inside the update feed both come from it, so they cannot drift apart. - Update
VERSION. - Run the Release workflow from the Actions tab with
publishleft false. That is a dry run: it builds, signs, notarizes, staples, packages, checksums and generates the feed, and creates no tag, no release and no commit anywhere. The artifacts are attached to the run under a short retention name so you can download and check them. - Install the dry run's DMG and use it.
stapler validateon the app and on the DMG, andshasum -c SHA256SUMSfrom a different directory. - Run the workflow again with
publishtrue. It creates the tag and a pre-release with the DMG andSHA256SUMS. - Only once that release page exists, commit the generated
appcast.xmlto the feed repository. A feed pointing at a download that is not there yet breaks updates for everyone who reads it in the meantime. - Confirm an already installed older copy finds the update, verifies it and installs it, ending on the new version. Do this before announcing anything: a broken updater cannot be fixed by a later release.
Everything the workflow runs is a script in scripts/, and each one works on its own from a
terminal. The workflow wraps them; it does not reimplement them.
Two steps fail silently if they are done in the wrong order, and both are wired that way on purpose:
- Sign inside out. Sparkle's nested XPC services, then
Autoupdate, thenUpdater.app, then the framework, then the bundled command, then the app.--deepis for verification only, never for signing. - Sign the update last. The feed signature covers the exact bytes of the file people download, so
it is taken after notarizing, stapling and packaging. Anything that rewrites the artifact
afterwards leaves a signature that no longer matches, and Sparkle then refuses the update without
telling anyone.
scripts/appcast.shrefuses to sign an artifact that is not stapled, and checks the byte length it writes against the file on disk.
The log is fetched and saved on every submission, including successful ones, under
build/notarization/. notarytool submit reports Accepted, Invalid or Rejected; for anything
but Accepted, the log is the only thing that says why.
xcrun notarytool log <submission-id> \
--key <p8> --key-id <key-id> --issuer <issuer-id>It is JSON. issues is the part that matters, and each entry names a path inside the bundle, a
severity and a message. The usual ones:
| Message | What it means |
|---|---|
The signature of the binary is invalid |
Something was modified after signing, or signed in the wrong order. |
The executable does not have the hardened runtime enabled |
A nested binary was missed. Check each one on its own, not with --deep. |
The signature does not include a secure timestamp |
Signed without --timestamp, usually offline. |
The binary is not signed with a valid Developer ID certificate |
An Apple Development identity was used. |
status: Accepted with a non-empty issues list is possible and is worth reading: those are
warnings that become failures in a later macOS.
Nothing before the publish step changes anything outside the runner, so a failure there costs nothing: fix it and run the dry run again.
After that, work backwards from how far it got.
The tag and release exist, but the artifacts are wrong. Delete the release and the tag, then run again. Do this only if nobody can have downloaded it yet, and never once the feed points at it.
gh release delete v<version> --yes
git push --delete origin v<version>The release is published and the feed is not updated yet. Nothing is broken. Either finish by committing the feed, or leave it: people who downloaded the DMG have a working app, and no installed copy is looking for that version.
The feed points at a release that is wrong or gone. This is the one that hurts, because installed copies are reading it. Revert the feed repository to its previous commit first, which stops the damage, then fix the release. Do not delete the release while the feed still points at it.
Notarization is stuck in progress. Submissions can take minutes or hours. notarytool history
shows where it is. Do not resubmit: a second submission of the same bytes gets a second ticket and
tells you nothing new.
The published version is broken for everyone. Release a fixed version rather than trying to undo one. Mark the bad release as a pre-release or delete it so nobody new downloads it, publish the fix, and update the feed. Installed copies will update themselves. Never reuse a version number: Sparkle compares build numbers, and a copy that has already seen that number will not offer it again.
The release runs on a pinned runner image and asks for a preferred Xcode major, falling back to the newest installed Xcode with a macOS SDK of 26.0 or newer. Both are workflow inputs, so a runner image that changes or is withdrawn can be worked around by dispatching with a different one rather than editing a file under time pressure.
The SDK floor is the real requirement. The linked SDK decides whether AppKit gives the app its current appearance or its pre macOS 26 one, and an older SDK builds, signs and notarizes perfectly while shipping a window that looks years out of date. Every run prints each Xcode on the runner with its SDK version and records the one it chose, so what shipped is never a matter of inference.