Skip to content

fix(build): always sync canonical framework binary to avoid per-copy signature divergence#1258

Merged
ErikBjare merged 2 commits intoActivityWatch:masterfrom
TimeToBuildBob:fix/always-sync-framework-binaries
Apr 10, 2026
Merged

fix(build): always sync canonical framework binary to avoid per-copy signature divergence#1258
ErikBjare merged 2 commits intoActivityWatch:masterfrom
TimeToBuildBob:fix/always-sync-framework-binaries

Conversation

@TimeToBuildBob
Copy link
Copy Markdown
Contributor

Root cause

PR #1257 added a pre-signing SHA guard to detect whether Python.framework/Python, Versions/Current/Python, and Versions/3.9/Python are duplicates before syncing from canonical. The guard fails because PyInstaller already signed each copy independently (via codesign_identity in aw.spec) during the watcher build, giving each path a different embedded signature (different timestamp nonce).

After cp -r into the reassembled .app, all three paths carry pre-existing but different signatures. The SHA comparison never matches, all three fall through to the "sign separately" path, and Apple rejects all three as "The signature of the binary is invalid".

Evidence from post-#1257 master Build Tauri run 24217849800 — all 9 paths still rejected (3 watchers × 3 Python.framework paths), confirming the dedup never fired.

Fix

Remove the SHA guard and always sync the signed canonical to all other paths in the Python.framework fallback. These paths are always the same Python binary; independently signing them is what causes the Apple rejection.

This reduces the fallback block from ~30 lines to ~10 lines.

Why this is safe

  • We're only in this fallback when codesign reports "bundle format is ambiguous" — specifically for non-standard PyInstaller-embedded Python.framework bundles
  • Python, Versions/Current/Python, Versions/3.9/Python are always the same binary (different alias paths for the same Python interpreter)
  • Apple requires byte-identical signatures across all three — unconditional sync is the only approach that achieves this

…signature divergence

PyInstaller signs each Python.framework copy independently during the watcher
build (via codesign_identity in aw.spec), giving each path a different embedded
signature (different timestamp nonce). After cp -r into the reassembled .app,
the three paths (Python, Versions/Current/Python, Versions/3.9/Python) carry
pre-existing but DIFFERENT signatures.

The ActivityWatch#1257 pre-sign SHA guard was intended to detect these as duplicates, but
fails precisely because the existing PyInstaller signatures differ — the SHA
comparison never matches, all three fall through to the 'sign separately' path,
and Apple rejects all three as 'The signature of the binary is invalid'.

Fix: remove the SHA guard and always sync the signed canonical to every other
path in the framework. These are always the same Python binary; any independent
signing produces divergent signatures that Apple rejects.
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 10, 2026

Greptile Summary

This PR removes the SHA-comparison guard introduced in #1257 and replaces it with unconditional syncing of the signed canonical Python.framework binary to all duplicate paths. The root cause was that PyInstaller independently signs each copy of the framework binary during the watcher build, so the SHA values never match and all three copies fell through to separate signing, producing divergent signatures that Apple's notarizer rejects. The post-increment arithmetic bug (((synced_count++))) flagged in the previous review was also corrected to synced_count=$((synced_count + 1)).

Confidence Score: 5/5

Safe to merge — the broken SHA guard is removed, the arithmetic set -e hazard is fixed, and temp-file cleanup is handled on all code paths.

No P0 or P1 findings remain. The fix is narrowly scoped to the 'bundle format is ambiguous' fallback and the unconditional sync is the only approach that satisfies Apple's requirement for byte-identical signatures across all three Python.framework binary paths.

No files require special attention.

Important Files Changed

Filename Overview
scripts/package/build_app_tauri.sh Fallback block for 'bundle format is ambiguous' now unconditionally syncs the signed canonical binary to all other Python.framework paths; set -e-safe arithmetic fix included; temp-file cleanup guarded on both success and failure paths.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Sign .framework bundle with codesign] -->|Success| B[Bundle signed ✓]
    A -->|Fails: 'bundle format is ambiguous'| C[Fallback: PyInstaller non-standard framework]
    C --> D[Find all Mach-O binaries inside .framework]
    D --> E{Any binaries found?}
    E -->|No| F[ERROR: exit 1]
    E -->|Yes| G[Extract existing code-sign Identifier from canonical]
    G --> H[cp -p canonical → tmp_binary]
    H --> I[codesign --force --timestamp tmp_binary]
    I -->|Failure| J[rm tmp_binary; exit 1]
    I -->|Success| K[cp tmp_binary → canonical]
    K -->|Failure| L[rm tmp_binary; exit 1]
    K -->|Success| M[rm tmp_binary]
    M --> N[For each remaining path in fw_bins at 1 ...]
    N --> O[cp canonical → fw_bin — unconditional sync]
    O --> P{More paths?}
    P -->|Yes| N
    P -->|No| Q[Log: Signed 1 + synced N paths]
Loading

Reviews (2): Last reviewed commit: "fix(build): use safe arithmetic for sync..." | Re-trigger Greptile

Comment thread scripts/package/build_app_tauri.sh Outdated
((synced_count++)) post-increments: when synced_count=0, the expression
evaluates to 0, returning exit status 1 which triggers set -e and exits
the script after only the first non-canonical path is synced. The third
Python.framework copy (Versions/Current/Python) would be left with its
old PyInstaller signature, reproducing the Apple rejection.

Use synced_count=$((synced_count + 1)) which always returns exit status 0.
@TimeToBuildBob
Copy link
Copy Markdown
Contributor Author

Fixed Greptile's P1 finding in d2d31a3: changed ((synced_count++))synced_count=$((synced_count + 1)). The post-increment form evaluates to 0 on the first iteration which triggers set -e exit, leaving the third Python.framework copy (Versions/Current/Python) unsigned and divergent — exactly the Apple rejection this PR aims to fix.

@TimeToBuildBob
Copy link
Copy Markdown
Contributor Author

@greptileai review

@TimeToBuildBob
Copy link
Copy Markdown
Contributor Author

Ready to merge — Greptile reviewed the fixed commit (d2d31a3) and scored 5/5 with "Safe to merge". All CI checks are passing. Waiting on a maintainer click per your note on ErikBjare/bob#546.

@ErikBjare ErikBjare merged commit 1a349e0 into ActivityWatch:master Apr 10, 2026
17 checks passed
TimeToBuildBob added a commit to TimeToBuildBob/activitywatch that referenced this pull request Apr 11, 2026
…llback

PyInstaller's codesign_identity stamps each Python.framework copy with a
different signature (different timestamp nonce). When codesign tries to sign
a non-standard PyInstaller framework as a bundle, it signs each Mach-O binary
independently, producing different __LINKEDIT sections. Apple notarization
rejects ALL copies as 'The signature of the binary is invalid'.

Previous attempts (ActivityWatch#1250-ActivityWatch#1258) tried various dedup strategies (cmp against
unsigned ref, SHA guards, unconditional sync) but all failed because they
compared binaries that already contained different PyInstaller signatures.

Fix: strip ALL existing signatures with codesign --remove-signature before
the fallback pass. This makes all copies of the same binary truly byte-identical
(only the code page remains). Then sign the canonical (first) binary once and
cp -p the result to all duplicate paths. The --remove-signature step is the key
insight that previous attempts missed.

Only triggers when codesign fails with 'bundle format is ambiguous' on a
framework directory. Standard frameworks (with Info.plist and symlinked
Versions/) continue to use the normal codesign bundle signing path.
TimeToBuildBob added a commit to TimeToBuildBob/activitywatch that referenced this pull request Apr 13, 2026
…igning fallback

Rebases on top of ActivityWatch#1255-ActivityWatch#1258 and adds two correctness improvements:

1. Strip all existing signatures before comparison, so content hashing
   identifies true duplicates rather than nonce-only signature differences
   from PyInstaller's pre-sign codesign_identity step.

2. Group binaries by SHA-256 content hash instead of treating the
   whole framework as one duplicate set. This correctly handles the
   (unlikely but possible) case where a framework contains genuinely
   different Mach-O files — only true duplicates share a signed payload.

Both changes preserve master's existing patterns: identifier preservation
via temp copy, codesign --force --options runtime, and the ambiguous-
framework fallback structure.
ErikBjare pushed a commit that referenced this pull request Apr 13, 2026
…igning fallback (#1259)

Rebases on top of #1255-#1258 and adds two correctness improvements:

1. Strip all existing signatures before comparison, so content hashing
   identifies true duplicates rather than nonce-only signature differences
   from PyInstaller's pre-sign codesign_identity step.

2. Group binaries by SHA-256 content hash instead of treating the
   whole framework as one duplicate set. This correctly handles the
   (unlikely but possible) case where a framework contains genuinely
   different Mach-O files — only true duplicates share a signed payload.

Both changes preserve master's existing patterns: identifier preservation
via temp copy, codesign --force --options runtime, and the ambiguous-
framework fallback structure.
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