Field notes · July 2026

Ten traps between a working Mac app and a customer who can open it

We spent July 2026 shipping a paid Mac app outside the App Store: Developer ID signing, notarisation, DMG packaging, Sparkle updates, licence checks. The code was the easy part.

What follows is every trap that cost us a day or burned a release, in the order you'll meet them. Each one is written symptom-first, because that's how they introduce themselves.

Most of them fail silently. The build is green, the check passes, and the damage surfaces on a machine you don't own — sometimes months later. That delay is what makes shipping outside the App Store expensive. Not the difficulty.

01The default on Apple's certificate page expires your certificate

Symptom. None, for up to a year. Then your Developer ID stops working in February 2027 — and the expiry date printed on your certificate says otherwise.

Why. Creating a Developer ID Application certificate involves an intermediary screen where Apple may pre-select "Previous Sub-CA". Certificates issued against that intermediate all die in February 2027 regardless of what your own certificate claims.

Fix. On that screen, choose G2 Sub-CA. It is one radio button, it is not the default, and nothing later in the process warns you.

Two related facts worth knowing before you start: only the Account Holder of the developer account can create this certificate — if you're on a team and you aren't them, you'll be stuck until they act. And the private key sitting in your keychain is not replaceable; export it as a .p12 and store it somewhere safe the day you create it.

02errSecInternalComponent means “keychain permissions”, and says neither word

Symptom. codesign fails with errSecInternalComponent. That's the whole message.

Why. Your keychain hasn't granted codesign non-interactive access to the signing key. Nothing in the error mentions keychains, permissions, or the key.

Fix. Run this once:

security set-key-partition-list -S apple-tool:,apple:,codesign: \
  -s ~/Library/Keychains/login.keychain-db

It asks for your login password once. Our release script now checks for a usable identity before every run and prints this exact command when it's missing — an error is only useful if it names the fix.

03Xcode ad-hoc signs the binaries inside your SPM packages

Symptom. The app runs perfectly on your Mac. Notarisation comes back rejected, with one complaint per nested binary you didn't know existed.

Why. Binary frameworks pulled in through Swift Package Manager — Sparkle is the usual suspect, and it brings an Updater, an Autoupdate helper and two XPC services — are embedded with an ad-hoc signature. Signing the outer app does not re-sign what's inside it.

Fix. Sign inside-out: nested bundles, then helpers, then frameworks, then the app itself, each with hardened runtime and a timestamp. Then verify before you spend a submission:

codesign --verify --deep --strict "MyApp.app"

And date your proof. Any embedded component added after a successful round invalidates that round — the next dependency you add is the next rejection.

04The zip you send to Apple is not the zip you ship

Symptom. Everything passed. Months later a user says the app won't open — and only some users, seemingly at random.

Why. Notarisation takes an archive, and the ticket comes back after. If your release script publishes the archive it built for submission, it publishes the app without the stapled ticket. A machine that can reach Apple validates online and opens it fine. A machine that can't — locked-down network, offline install — refuses. Your test machine is almost always the first kind.

Fix. Build the distribution archive after stapling, from the stapled app, and make the validation a hard gate rather than a note in the README:

xcrun stapler staple "MyApp.app"
ditto -c -k --keepParent "MyApp.app" "MyApp-1.0.1.zip"   # not zip -r: preserves the bundle
xcrun stapler validate "MyApp.app" || exit 1

05Sparkle's own tool will hand you an unsigned feed and say nothing

Symptom. Updates never install. No error on your side, ever.

Why. generate_appcast produces an unsigned feed, with no warning, if the app inside your archive was built before the signing key was configured. An unsigned feed is silently rejected by every user's copy of your app. This one specifically bites anyone who ships 1.0 first and adds updates later — which is almost everyone.

Fix. Have your publish script assert that edSignature exists on every enclosure after generation, and refuse to publish if it doesn't. Never remove that check because “it's obviously signed”.

06A valid signature is not the same as the right key

Symptom. The feed is signed. Your signature check passes. Every user's updater still refuses to install anything.

Why. The feed is signed with the private key in your keychain. The app validates with the SUPublicEDKey compiled into it. Nothing in the pipeline compares the two. If they've drifted — a different machine, regenerated keys, a template value nobody replaced — you get a correctly signed feed that no copy of your app will ever accept, and no error anywhere you can see.

Fix. Compare them before signing:

KEYCHAIN_PUB=$(generate_keys -p)
APP_PUB=$(defaults read "MyApp.app/Contents/Info.plist" SUPublicEDKey)
[[ "$KEYCHAIN_PUB" == "$APP_PUB" ]] || { echo "key mismatch — refusing to sign"; exit 1; }

07The archive URL comes from wherever the feed lives

Symptom. The update appears, the user clicks Install, nothing happens.

Why. generate_appcast derives each enclosure URL from the directory containing the feed. Publish the feed at /updates/appcast.xml and the archive at /updates/archives/, and the feed points at a URL that 404s. Sparkle swallows a 404 without telling the user.

Fix. Archives go in the same directory as the feed. Have the publish script print the full enclosure URL it just created, so you can click it. We also check that every length= in the feed matches the real file byte-for-byte before publishing — a mismatch is the same silent failure in a different costume.

08A single-architecture build quietly cuts off every Intel user

Symptom. Intel users never see updates. They don't get an error; the update simply doesn't exist for them.

Why. If your build produces an Apple-Silicon-only binary, generate_appcast marks the update arm64-only, and Sparkle correctly hides it from every Intel Mac. Nobody is notified.

Fix. Worth being precise, because we got this wrong at first and had to measure it: a plain xcodebuild build can produce a universal binary — what decides it is where ARCHS and ONLY_ACTIVE_ARCH come from. Passed as command-line overrides without a -destination, you get x86_64 arm64. Left to the scheme's defaults, you get whatever your Mac is.

Force it explicitly in the release script and check the result with lipo -archs rather than trusting the settings.

09Gatekeeper's warning is triggered by whoever downloads the file — not by you

Symptom. You build a DMG, double-click it, the app opens with no warning, and you conclude your signing is correct. It proves nothing at all.

Why. The quarantine flag is written by whatever downloads the file — Safari, Mail, Messages. A DMG you just built on your own machine is born without it, so Gatekeeper never engages. An unsigned app would open just as quietly.

Fix. Simulate the download before testing. Two details matter, and we burned three attempts learning them:

hdiutil detach "/Volumes/MyApp" 2>/dev/null       # stamping a MOUNTED image does nothing
xattr -w com.apple.quarantine \
  "0081;$(printf '%x' "$(date +%s)");Safari;$(uuidgen)" "MyApp.dmg"

Quarantine transfers to the volume at mount time, so an image stamped while already mounted hands out clean copies. And drag the app out in Findercp and ditto do not propagate quarantine; only Finder does.

What you should then see on first launch:

“MyApp” is an app downloaded from the Internet. Are you sure you want to open it?
Apple checked it for malicious software and none was detected.

That second sentence is what notarisation bought you. If you get “unidentified developer” instead, it didn't work.

10macOS remembers your approval by signature, so you only get to test it once

Symptom. You want to see that first-launch prompt again. You delete the app, empty the Trash, rebuild, re-stamp — and it never comes back.

Why. The approval is recorded against the code signature, not the path. Deleting copies changes nothing. Worse: rebuilding unchanged source produces a byte-identical binary with the same signature, so macOS still recognises it as the one you already approved.

Fix. To see the prompt again you have to change the signature — a real source change or a build-number bump. We keep a list of signatures that have already been through a first launch and have the script refuse to start that test with one of them, because a hollow test looks exactly like a passing one.

Useful while rehearsing: Cancel does not consume the approval. Only Open does.

The one we found last, and it isn't about signing at all

Auto-updates need a feed and an archive that anyone can fetch without credentials. That's how the updater works, and no access control survives it — anything that stops a stranger stops every customer's updater too. So the archive you publish is, by design, downloadable by everyone.

Which means any secret inside that app belongs to everyone who can download it. If your app carries the thing you're selling — encrypted, along with the key that opens it — the lock is theatre. We proved it against our own release in three commands, a day after publishing it.

There is no client-side fix for this. Either the payload isn't in the app, or the key arrives from a server after a licence check, where it can be rotated and revoked. Anything compiled into the binary is public the moment the binary is.

We took our update feed down the day we measured it, and it goes back up when the key comes from somewhere we control.

The same logic applies to the licence check in whatever you ship. Anything that decides “paid or not” inside the binary can be patched out by someone who wants to; client-side licensing is a speed bump, not a lock. It's worth having — casual copying stops being effortless — but if anyone tells you their scheme is unbreakable on the client, they haven't tried to break it.

What this cost, in one line

Ten traps, eight of which produce no error message on the machine you're standing at. The other two produce one that names nothing.

Every fix above is a few lines of shell — none of this is hard once you know it exists. That's the whole problem: you find out from a customer.

The scripts we ended up with, with all ten guards wired in, are what we sell as Hawser — $99, one-time. Everything above works without it.

Hawser — one file to configure, one command to ship.
$99, one-time.
See what's in it