The extensionless-binary trap in macOS notarization

Tagged as macos, dv2mv, packaging

Written on 2026-06-22 22:10:00

Or: Apple code signing fussiness

I packaged music chop — a PyInstaller-frozen Python GUI that bundles ffmpeg, ffprobe, and rubberband — into a macOS .app, and went to sign and notarize it so Gatekeeper would just stop with the warnings. Here's the whole chain:

  1. Apple Developer Program ($99/yr). The approval is the prerequisite
  2. A Developer ID Application certificate. Make it in Xcode → Settings → Accounts → (team) → Manage Certificates → + → Developer ID Application. This needs Account Holder / Admin — the plain "Developer" role can't create one. It installs the cert and its private key into your login keychain; without the private key, security find-identity -v -p codesigning shows nothing.
  3. codesign, hardened runtime, with entitlements for whatever your app does that the runtime would otherwise block (mine needs allow-jit + allow-unsigned-executable-memory for numba, and disable-library-validation for the bundled third-party dylibs).
  4. notarize with xcrun notarytool submit --wait, then staple the ticket.

The keychain prompt that won't quit

codesign signs every nested binary individually — dozens of them. Click Allow and it grants one file, then prompts for the next. Even Always Allow can keep re-asking. Grant Apple's tools persistent access to the key once and be done:

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

The actual trap

notarytool came back status: Invalid. The useful move here is to pull the per-file log — it tells you exactly what failed:

xcrun notarytool log <submission-id> --keychain-profile <your-profile>

Three binaries, same three errors each: not signed with a valid Developer ID, no secure timestamp, hardened runtime not enabled — for Contents/Frameworks/bin/{ffmpeg,ffprobe,rubberband}. Everything else signed fine.

Why? My signing loop matched by extension:

find "$APP/Contents" \( -name "*.dylib" -o -name "*.so" \) ...

But ffmpeg and friends are Mach-O executables with no extension. The glob skipped them, so they shipped with their original (non-Developer-ID) signatures. The fix is to match by file type, not name:

find "$APP/Contents" -type f -print0 | while IFS= read -r -d '' f; do
  if file -b "$f" | grep -q "Mach-O"; then
    codesign --force --timestamp --options runtime -s "$DEVELOPER_ID" "$f"
  fi
done

Don't rebuild — re-sign

When only the signing was wrong, the bundle itself is fine. Skip the slow PyInstaller run: re-sign all the nested Mach-O with the loop above, re-sign the .app bundle with your entitlements, rebuild the .dmg around it, and resubmit. And run the wait in the background — if your local notarytool --wait gets killed, Apple keeps processing server-side; just xcrun notarytool wait <id> (or info) to pick the verdict back up.

The whole recipe — preconditions, identity auto-detection (no hardcoded Team ID, which isn't secret anyway — it's stamped into every app you ship), and the failure handling — is a self-contained skill in the repo at .claude/skills/sign-macos/SKILL.md. Steal it.


Unless otherwise credited all material Creative Commons License by Kevin Griffin