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.
Everything below was hit and fixed on macOS 26.5 with Xcode 26.6, in July 2026, on a solo Developer Program account. Where Apple's behaviour depends on a version or a screen that can change, assume it has changed and check before you rely on our date.
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 Finder, because cp 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, since 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.
Knowing the ten is not the same as checking them
You have the list now. Take it, it's yours. But notice what the list actually asks of you: not to solve ten problems once, but to re-verify ten invariants on every single release, forever, most of which look identical whether they passed or failed.
We know how that goes, because we wrote this list and then shipped a broken build anyway. Our proof run was green in the morning. We added Sparkle in the afternoon, which quietly introduced four unsigned nested binaries, and the next real submission came back Invalid. Having written trap 03 did not save us from trap 03. Only re-running the whole pipeline did.
That is the part that does not fit in a blog post, and it's the part we ended up building. ship.sh and appcast.sh refuse to continue at 35 separate points, and each refusal prints the fix rather than a status code. A sample of what runs on every release, whether or not you remember why:
# the entitlement that survives a re-sign and gets you rejected
codesign -d --entitlements - --xml "$APP" | grep -q 'get-task-allow' && fail \
"get-task-allow still present after signing — notarization would reject this."
# the archive you're about to publish, with no ticket in it
stapler validate "$INNER" || fail \
"$(basename "$archive") contains an app with NO stapled ticket."
# the correctly signed feed that every copy of your app will reject
[[ "$APP_ED_KEY" == "$EXPECTED_ED_KEY" ]] || fail \
"built with a DIFFERENT Sparkle public key than the one signing this feed."
None of these fire on a good day. They exist for version 1.4.2, eight months from now, at one in the morning, when you have long since stopped thinking about February 2027 and Sub-CAs.
And they keep moving. macOS 27 is in beta as we write this. Apple has changed the rules of this pipeline before and will again; when that happens, our scripts get updated because we ship our own apps through them. Yours get updated when a customer emails you.
That is what Hawser is, and why it costs $99 once rather than nothing: the ten fixes above are free and always will be, the thing that runs them for you is not.
What it doesn't do
Better said here than discovered after you've paid.
- No sandboxed / Mac App Store variant. This is the outside-the-store pipeline. If you need to ship both ways, the second configuration is yours to write.
- Licensing is Gumroad only: no Paddle, no Lemon Squeezy, no Stripe. If you've already chosen a different provider, that layer is on you.
- No CI templates.
ship.sh runs on your machine, not in GitHub Actions. Nothing stops you wiring it up; we haven't done it for you.
- Swift and SwiftUI, targeting macOS 13 or later. No Objective-C skeleton, no Catalyst, no iOS.
- The licence check runs on the client, with everything that implies; see two sections up. It makes casual copying inconvenient. It is not a lock, and we won't sell it as one.
- It doesn't remove the Apple Developer Program fee. That's $99 a year to Apple, and you pay it whether or not you buy anything from us.