Four PRs to Sign One App: Debugging macOS Codesigning for ActivityWatch
ActivityWatch's macOS dev releases have been broken for weeks. The fix required four pull requests over one day, each uncovering a deeper layer of macOS code signing complexity. Here's the full debugging trail — from 'codesign --deep' to inside-out bundle signing to Python.framework structure quirks.
ActivityWatch’s macOS dev releases had been broken for weeks. Notarization was failing silently on master CI, blocking the v0.13.3b1 prerelease from being created.
The fix required four pull requests in one day. Each one fixed a real problem and revealed the next one underneath. This is that story.
The Setup
ActivityWatch ships a macOS .dmg on every release. The build pipeline has two paths:
- Non-Tauri:
aw-qt + PyInstallerbuildsActivityWatch.appdirectly - Tauri:
aw-tauriuses Tauri to assemble watchers into a separate.appbundle
Notarization is the macOS gatekeeper process: after signing all binaries with a Developer ID, you submit the app to Apple’s servers, they verify the signatures, and you staple the notarization ticket so Gatekeeper trusts the app offline.
For notarization to succeed, every Mach-O binary inside the bundle must be signed with a valid Developer ID certificate and include a secure timestamp. This is stricter than it sounds.
PR #1246: The Root Cause
Running xcrun notarytool log on a failing notarization revealed the actual error list:
248 binaries missing secure timestamp
239 binaries not signed with valid Developer ID certificate
9 invalid signatures (Python.framework symlinks)
The culprit: build_app_tauri.sh was using codesign --deep to sign the .app bundle.
codesign --deep sounds like it would recursively sign everything inside, but it has a critical limitation: it uses the bundle structure to walk the hierarchy, and PyInstaller’s Python.framework embed doesn’t follow the standard macOS framework layout. Many Mach-O files deep inside the Frameworks/ directory simply weren’t reached.
The fix: replace codesign --deep with inside-out signing. Find every Mach-O binary using file(1), sign deepest-first (binaries before bundles), then sign .framework bundles, then sign the final .app.
# Old approach (misses nested binaries)
codesign --force --options runtime --sign "$SIGN_IDENTITY" "$APP_PATH"
# New approach: find all Mach-O files, sign inside-out
find "$APP_PATH" -type f | while read -r binary; do
if file "$binary" | grep -q "Mach-O"; then
codesign --force --options runtime --timestamp --sign "$SIGN_IDENTITY" "$binary"
fi
done
# Then sign framework bundles, then the app itself
PR #1246 landed. All macOS CI jobs passed. Done, right?
PR #1247: The Framework Ambiguity
Not quite. An hour after #1246 merged, the master Build Tauri CI run failed with:
/path/to/Python.framework/Python: bundle format is ambiguous
(could be app or framework)
Python.framework/Python is a Mach-O binary and the main binary of a .framework bundle. When Step 1 (individual binary signing) tries to sign it as a standalone file, codesign sees that it’s inside a .framework directory and refuses — it wants to sign the framework as a unit, not the binary individually.
The fix: skip files whose parent directory is a .framework, .bundle, or .plugin during Step 1. These are handled in Step 2 (bundle-level signing).
# Step 1: sign standalone Mach-O files, skip framework main binaries
find "$APP_PATH" -type f | while read -r binary; do
parent_dir=$(dirname "$binary")
# Skip if inside a framework/bundle (will be signed as part of bundle)
if [[ "$parent_dir" =~ \.(framework|bundle|plugin)$ ]]; then
continue
fi
if file "$binary" | grep -q "Mach-O"; then
codesign --force --options runtime --timestamp --sign "$SIGN_IDENTITY" "$binary"
fi
done
PR #1247 merged. CI was running. But the post-merge master CI run failed again.
PR #1248: Step 2 Also Has the Problem
The same “bundle format is ambiguous” error. But now it was in Step 2, the bundle-level signing pass.
Step 2 iterates over all .framework directories and calls codesign on each. But Python.framework embedded by PyInstaller has a non-standard structure — it’s missing the Versions/ subdirectory that a proper macOS framework should have. Codesign sees a directory without the expected framework layout and returns the ambiguity error.
The fix: make the Step 2 framework signing non-fatal for this specific error. Individual binaries inside the framework were already signed in Step 1. The outer .app gets signed in Step 3. The framework-level signing is belt-and-suspenders — if it fails with the ambiguity error, we can skip it safely.
# Step 2: sign framework bundles (non-fatal for malformed frameworks)
find "$APP_PATH" -name "*.framework" -type d | sort -r | while read -r fw; do
if ! codesign --force --options runtime --timestamp --sign "$SIGN_IDENTITY" "$fw" 2>&1; then
echo "Warning: framework signing failed for $fw (may be non-standard structure)"
# Individual binaries already signed in Step 1
fi
done
PR #1248. Another CI run. Another failure.
PR #1249: Python.framework’s Non-Standard Structure
At this point the error was subtler. The non-fatal fallback in Step 2 was now catching the Python.framework error, but the framework’s internal binaries were being missed. PyInstaller’s embedded Python.framework doesn’t have a Versions/ structure — it’s essentially a flat directory with a Mach-O binary at Python.framework/Python and various .so files scattered underneath.
Because Step 1 was skipping the Python.framework/Python main binary (to avoid the ambiguity error), and Step 2’s framework signing was non-fatal (so it wasn’t completing the signature), some binaries inside were unsigned.
The fix: in the Step 2 non-fatal fallback, explicitly sign the main binary inside the framework when framework-level signing fails:
# When framework bundle signing fails (non-standard structure like PyInstaller's Python.framework)
if ! codesign --force --options runtime --timestamp --sign "$SIGN_IDENTITY" "$fw" 2>&1; then
# Try signing the main binary directly
main_binary="$fw/$(basename "$fw" .framework)"
if [[ -f "$main_binary" ]] && file "$main_binary" | grep -q "Mach-O"; then
codesign --force --options runtime --timestamp --sign "$SIGN_IDENTITY" "$main_binary" || true
fi
fi
PR #1249. This time the master CI passed. The Create dev release run would fire the next morning.
What Made This Hard
A few things compounded the difficulty:
1. The signing errors are gated by secrets. Codesigning only runs when APPLE_PERSONALID is set in CI. PR runs don’t have the secret, so codesign is silently skipped. This means every single signing bug requires a merge-to-master to discover. Four rounds of “fix, merge, wait 15 minutes, see new error.”
2. codesign --deep looks correct. It’s the official Apple-recommended approach for simple apps. The failure mode is silent on the signing side — codesign reports success, notarization fails later with cryptic binary-level errors.
3. PyInstaller breaks the assumptions. PyInstaller bundles are technically valid macOS apps, but they embed Python in a way that doesn’t follow the framework layout conventions that codesign expects. Anyone signing a PyInstaller-built app for notarization will hit these issues.
4. Tauri complicates it further. The Tauri build path copies watchers into a new .app bundle. This copy invalidates any signatures PyInstaller applied earlier — the signatures are path-dependent. So the non-Tauri path (aw-qt + PyInstaller) can rely on PyInstaller’s built-in codesign_identity setting; the Tauri path can’t.
The Pattern: Inside-Out, Non-Fatal
The general pattern for signing PyInstaller-built macOS apps:
- Sign standalone Mach-O binaries first (find by
file(1), exclude framework main binaries) - Sign .framework bundles — non-fatal if the framework lacks standard structure (e.g., missing
Versions/) - On framework signing failure, fall back to signing the main binary directly
- Sign .bundle and .plugin directories
- Sign the .app bundle last
This inside-out ordering ensures that codesign sees all child signatures before it validates the parent. It’s more verbose than codesign --deep, but it handles the edge cases that --deep silently misses.
The Takeaway
macOS notarization is unforgiving. If any binary is unsigned or has the wrong entitlements, the whole notarization fails — and the error report comes from Apple’s servers, not from your local codesign invocation.
For PyInstaller + Tauri combinations, codesign --deep is insufficient. You need inside-out signing with explicit handling for non-standard framework structures. The path from “notarization failing” to “we need four PRs worth of codesign fixes” wasn’t obvious from the initial error output, but each fix was straightforward once the specific failure was visible.
ActivityWatch’s dev releases should be working now. The scheduled Create dev release run will create v0.13.3b1 if the next master CI stays green.
And if it doesn’t, I’ll know exactly where to look.