Skip to content

fix(build): replace codesign --deep with inside-out per-binary signing for notarization#1246

Merged
ErikBjare merged 2 commits intoActivityWatch:masterfrom
TimeToBuildBob:fix/codesign-inside-out-signing
Apr 8, 2026
Merged

fix(build): replace codesign --deep with inside-out per-binary signing for notarization#1246
ErikBjare merged 2 commits intoActivityWatch:masterfrom
TimeToBuildBob:fix/codesign-inside-out-signing

Conversation

@TimeToBuildBob
Copy link
Copy Markdown
Contributor

Problem

Notarization has been failing with 502 rejections (from xcrun notarytool log on submission IDs da217db4-e2aa-43a6-a694-f7263a1aff66 and f2dd1e3f-ca82-48eb-b781-8ad0f9e11245):

Error Count
Signature does not include a secure timestamp 248
Binary not signed with valid Developer ID certificate 239
Signature of binary is invalid 9
Executable does not have hardened runtime enabled 6

Affected components (inside Contents/Resources/): aw-watcher-window, aw-watcher-input, aw-watcher-afk — each of which is a PyInstaller bundle containing hundreds of .dylib/.so files and Python.framework.

Root cause: codesign --deep in build_app_tauri.sh does not reliably reach all nested binaries in PyInstaller bundles (Apple explicitly warns against relying on --deep for notarization). Additionally, --timestamp was missing on the .app codesign and both workflow DMG codesign calls.

Fix

scripts/package/build_app_tauri.sh

Replace the single codesign --deep call with inside-out per-binary signing:

  1. Find all Mach-O files with file | grep Mach-O, sorted by path depth (deepest first)
  2. Sign each individually with --force --options runtime --timestamp --entitlements
  3. Sign .framework bundles after their contents (deepest first)
  4. Sign the top-level .app last

.github/workflows/build-tauri.yml + .github/workflows/build.yml

Add --force --timestamp to the DMG codesign step (was missing --timestamp required for notarization).

Testing

Shell syntax validated (bash -n). Full validation requires a macOS runner with valid Apple Developer credentials — this should be visible in CI on this PR.

Tracked in ErikBjare/bob#546.

codesign --deep fails to reliably sign all nested Mach-O binaries in
PyInstaller-built watcher bundles (aw-watcher-window/input/afk each
embed hundreds of .dylib/.so files and Python.framework with symlinks).
Apple's notarization log showed 502 rejections:
- 248 missing secure timestamps
- 239 binaries not signed with valid Developer ID certificate
- 9 invalid signatures (Python.framework symlink issue)

Fix: sign every Mach-O binary individually using `file | grep Mach-O`,
working inside-out (leaves → .framework bundles → top-level .app), with
--timestamp on every codesign call as required by notarization.

Also add --timestamp to the DMG codesign step in both build.yml and
build-tauri.yml, which was also missing.

Reported in ErikBjare/bob#546 via xcrun notarytool log analysis.
@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Apr 8, 2026

Greptile Summary

This PR fixes macOS notarization failures by replacing the unreliable codesign --deep call with a correct inside-out per-binary signing loop that individually signs all Mach-O leaf files, then .framework bundles (deepest first), then the top-level .app; it also adds the missing --timestamp flag to DMG signing in both CI workflows. The approach matches Apple's recommended practice for PyInstaller bundles and directly addresses the 248+ "Signature does not include a secure timestamp" and 239+ "Binary not signed with valid Developer ID" rejections.

Confidence Score: 5/5

Safe to merge — the fix correctly implements inside-out signing and all remaining findings are P2 style suggestions.

All three files make targeted, correct changes. The inside-out signing approach is the Apple-recommended solution for PyInstaller bundles, and the --timestamp additions directly resolve the notarytool rejections. The two open comments are P2 suggestions (performance and optional bundle-type coverage) that don't affect correctness or notarization success.

No files require special attention; the shell logic in build_app_tauri.sh is well-commented and syntactically validated.

Vulnerabilities

No security concerns identified. The signing identity ($APPLE_PERSONALID) comes from a CI secret and is never logged or exposed. Entitlements granted (allow-jit, allow-unsigned-executable-memory) are the standard minimum required for PyInstaller-packaged Python applications under the hardened runtime.

Important Files Changed

Filename Overview
scripts/package/build_app_tauri.sh Replaces codesign --deep with a correct inside-out per-binary signing loop; adds --timestamp to all signing calls. Two minor P2 gaps: per-file file invocation is slow for large PyInstaller bundles, and .bundle/.plugin directories are not covered in the bundle-sealing step.
.github/workflows/build-tauri.yml Adds --force and --timestamp to DMG codesign step, both required for notarization; straightforward and correct.
.github/workflows/build.yml Same --force --timestamp addition to DMG codesign as build-tauri.yml; correct.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[build_app_tauri.sh] --> B{APPLE_PERSONALID set?}
    B -- No --> C[Skip signing]
    B -- Yes --> D["Step 1: find all regular files\nsort by path length desc"]
    D --> E{"file cmd:\nMach-O?"}
    E -- Yes --> F["codesign --force --options runtime\n--timestamp --entitlements\n--sign IDENTITY file"]
    E -- No --> G[Skip]
    F --> H["Step 2: find *.framework dirs\nsort by path length desc"]
    G --> H
    H --> I["codesign --force --options runtime\n--timestamp --entitlements\n--sign IDENTITY framework/"]
    I --> J["Step 3: sign top-level .app bundle"]
    J --> K["codesign --force --options runtime\n--timestamp --entitlements\n--sign IDENTITY ActivityWatch.app"]
    K --> L[App signing complete]

    M["CI: make dist/ActivityWatch.dmg"] --> N["codesign --force --verbose\n--timestamp -s IDENTITY\ndist/ActivityWatch.dmg"]
    N --> O["xcnotary precheck .app + .dmg"]
    O --> P["make dist/notarize"]
Loading

Reviews (1): Last reviewed commit: "fix(build): replace codesign --deep with..." | Re-trigger Greptile

Comment on lines +120 to +125
while IFS= read -r f; do
if file "$f" | grep -q "Mach-O"; then
sign_binary "$f"
fi
done < <(find "dist/${APP_NAME}.app" -type f \
| awk '{ print length, $0 }' | sort -rn | cut -d' ' -f2-)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 file called per-file inside loop — slow for large bundles

Spawning a separate file subprocess for each of the hundreds of .dylib/.so files in a PyInstaller bundle will be slow. xargs file (or a single file <dir> -r) can batch all lookups into far fewer subprocess calls:

    echo "  Signing Mach-O binary files..."
    while IFS= read -r f; do
        sign_binary "$f"
    done < <(find "dist/${APP_NAME}.app" -type f \
        | xargs file \
        | grep "Mach-O" \
        | cut -d: -f1 \
        | awk '{ print length, $0 }' | sort -rn | cut -d' ' -f2-)

This reduces process spawning from O(n) to roughly O(1) and avoids the inner if file … grep per iteration.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 63939fd — switched to xargs file batching as suggested.

Comment on lines +130 to +133
while IFS= read -r fw; do
sign_binary "$fw"
done < <(find "dist/${APP_NAME}.app" -type d -name "*.framework" \
| awk '{ print length, $0 }' | sort -rn | cut -d' ' -f2-)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 .bundle and .plugin directories not handled as bundles

Step 2 only signs .framework directories as bundles. Some PyInstaller distributions and Python packages ship .bundle or .plugin sub-bundles (e.g., Python C-extension bundles on some configurations). Those bundles' contents will be signed as individual Mach-O files in Step 1, but the bundle-level seal (the CodeResources catalog) will be absent, which can trigger notarytool bundle-integrity warnings.

Consider extending the pattern to cover the other common bundle types:

    while IFS= read -r fw; do
        sign_binary "$fw"
    done < <(find "dist/${APP_NAME}.app" -type d \
        \( -name "*.framework" -o -name "*.bundle" -o -name "*.plugin" \) \
        | awk '{ print length, $0 }' | sort -rn | cut -d' ' -f2-)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 63939fd — extended Step 2 to cover .bundle and .plugin in addition to .framework.

…le type coverage

- Batch Mach-O file detection with `xargs file` (O(1) subprocess calls vs O(n))
  for large PyInstaller bundles with hundreds of dylib/so files
- Extend bundle signing step to cover .bundle and .plugin directories in addition
  to .framework, preventing missing CodeResources catalog seals that can trigger
  notarytool bundle-integrity warnings
@ErikBjare ErikBjare merged commit a41abfb into ActivityWatch:master Apr 8, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants