Unwait

Your DMG is notarized. The app inside it might not be.

· 4 min read macos notarization code signing release engineering

Signing, notarization, and stapling are three different things, and a ticket can be missing from four different places. How our release pipeline shipped a DMG whose inner app had no ticket, why that failure is invisible in your metrics, and the order of operations that fixed it.

We downloaded our own production DMG one day, mounted it, and ran one command against the app inside:

$ xcrun stapler validate /Volumes/Unwait/Unwait.app
Unwait.app does not have a ticket stapled to it.

The DMG itself validated fine. The auto-update tarball validated fine. The app inside the DMG, the thing every new user actually runs, had no ticket. This post is how that happens, why you will not see it in any dashboard, and the release order that prevents it.

Three words that are not the same thing

Signing proves who built the binary. Notarization is Apple's server-side malware scan of your upload, which records your app as known-good. Stapling downloads that verdict as a ticket and attaches it to the artifact, so Gatekeeper can verify it offline.

The part people skip is stapling, because everything seems to work without it. If the ticket is missing, Gatekeeper just asks Apple's servers at first launch, the check passes, the app opens. On your machine, on your office Wi-Fi, in every test you will ever run, a missing ticket is invisible.

It stops being invisible on a locked-down corporate network or an offline machine, where the online check cannot happen and the user gets "Apple cannot check it for malicious software" instead of your app.

And here is the part that makes it expensive: that failure produces no signal. The user downloaded, so your server logged a download. The app never launched, so there is no launch event, no first API call, no crash report, nothing. We wrote about reading a desktop funnel through a launch signal; a missing staple is one of the few failures that sits exactly in that silent gap, indistinguishable from a user who lost interest. You cannot debug what looks like churn.

How a pipeline freezes the wrong app

Our builds come out of Tauri, and the bundler's order is: build the .app, package the DMG from it, and only then does anyone notarize and staple. Whatever you staple afterward, the copy of the app inside the DMG was sealed before the ticket existed. The DMG container itself can be notarized and stapled, so every top-level check passes while the payload inside is naked.

Nothing about this is Tauri-specific. Any pipeline that packages first and notarizes second freezes a pre-staple app inside the artifact. The only reason we caught it is that we downloaded the shipped file and checked the inside, not the wrapper.

There is a second ordering trap on the way to fixing this. Stapling is a download of an existing verdict, so stapling something that was never notarized fails with the gloriously unhelpful Error 65: Record not found. If your release notes say "stapling worked last time", the likely explanation is that last time the artifact happened to be notarized already, which is exactly what bit us between two releases.

The order that works

What our release script does now, in sequence:

  1. Notarize the .app (notarytool submit --wait), then staple it.
  2. Rebuild the update tarball from the stapled app, so auto-updaters receive a ticketed app too. Fixing new installs only fixes half your users.
  3. Re-sign the tarball for the updater.
  4. Replace the app inside the DMG with the stapled copy, re-sign the DMG.
  5. Notarize the DMG, then staple the DMG.

Step 2 has its own trap on macOS, where tar quietly adds AppleDouble ._ entries that break the code signature when a non-Apple extractor unpacks them. That one cost us a broken release and got its own post.

Verify all four places, then refuse to ship

The lesson underneath both bugs is that we were validating the wrong objects. A ticket can exist or be missing in four places, independently:

  1. The bare .app
  2. The DMG container
  3. The app inside the DMG
  4. The app inside the update tarball

Our release script now checks all four and exits nonzero if any fails. The inner-DMG check is the one nobody does, and it is seven lines:

inner_stapled() {
  local mp rc=0
  mp=$(hdiutil attach "$1" -nobrowse -readonly | grep -o '/Volumes/.*' | head -1)
  xcrun stapler validate "$mp/YourApp.app" >/dev/null 2>&1 || rc=1
  hdiutil detach "$mp" -quiet
  return $rc
}

The tarball check extracts with a literal-minded parser (Python's tarfile, matching what the updater does) and validates the result. Mount, extract, validate what the user actually receives: the wrapper passing means nothing.

Keep the Apple password out of the build

One hygiene note. Tauri will notarize during the build if you export APPLE_* credentials into it, and we deliberately do not. The build process is a large amount of third-party code, and an app-specific Apple password does not belong in its environment. Instead the build produces signed-but-not-notarized artifacts, and the release script does notarization itself through a notarytool keychain profile (--keychain-profile), so the credential lives in the Keychain and never in env vars or shell history. Splitting build from notarization is also what makes the ordering above explicit instead of implicit in someone else's pipeline.

The checklist

The app this pipeline ships is Unwait, a macOS menu bar app that shows learning cards while your AI coding agent works. Every bug in this post was found the same way: by being our own first user.

Unwait does this for you

A macOS menu bar app that watches your Claude Code and Codex sessions, shows a short card while they work, and puts a strip on screen the moment one finishes. Free for two weeks, no card and no sign up.

Try for free
← All posts