Skip to content

fix(desktop): macOS installer ships an unopenable, unsigned app bundle - #558

Merged
Weegy merged 8 commits into
mainfrom
fix/desktop-mac-unsigned-bundle
Jul 31, 2026
Merged

fix(desktop): macOS installer ships an unopenable, unsigned app bundle#558
Weegy merged 8 commits into
mainfrom
fix/desktop-mac-unsigned-bundle

Conversation

@Weegy

@Weegy Weegy commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Problem

The published macOS app cannot be launched at all. Gatekeeper — and Apple's own syspolicy_check — reject it with a Fatal error:

Code has no resources but signature indicates they must be present.

Reproduced against the shipped v0.57.0 DMG; v0.56.0 is identically broken. It surfaced now only because v0.57.0 was the first release in a month (#556), so it is the first DMG anyone actually downloaded — a locally built .app carries no com.apple.quarantine xattr, so Gatekeeper never evaluated it and local runs masked the defect. Diagnostic tell: /Applications/omadia.app was dated release day, but its app-data had not been written since the previous local build, and there is no crash report — Gatekeeper blocks it before exec.

Root causes

Four distinct defects, each hiding behind the previous one.

1. The bundle was never signed. No Developer ID existed on this repo, so electron-builder skipped signing entirely (skipped macOS application code signing … 0 identities found). The app shipped with no Contents/_CodeSignature, while the Electron binary still carried its linker-signed ad-hoc signature — a combination macOS reads as corrupt (Identifier=Electron, Sealed Resources=none).

2. codesign could not have succeeded anyway — symlinks escaping the bundle. stage-runtime.mjs copies node_modules with cpSync({dereference:true}), which does not materialise npm's node_modules/.bin/* links. 58 of them survive as absolute links into the build machine's checkout. This presents as two different errors depending on where you look, which is what makes it easy to misdiagnose:

Links codesign says
On the build machine resolve — targets exist, just outside the .app invalid destination for symbolic link in bundle
Anywhere else dangle No such file or directory

Testing "does the target exist?" prunes nothing on CI. The correct property is "does the target stay inside the staged tree".

3. The bundle was too large to sign. Signing hashes every file, and macOS caps concurrent open files at kern.maxfilesperproc (10240 on the runner) regardless of ulimit -n — so electron-builder died with EMFILE. Raising the fd limit does not help; setting it to unlimited actively lowers the effective ceiling. Cutting the file count does.

4. Nested code must be signed bottom-up. Signing only the outer app leaves Contents/Frameworks/Electron Framework.framework unsealed and macOS still reports the same Fatal error. Note codesign --verify --strict passes on that state — only --deep catches it.

Changes

File Change
desktop/scripts/stage-runtime.mjs Prune symlinks whose target escapes the staged tree (realpathSync covers dangling, absolute and ../-escaping in one predicate); re-scan and fail the build if any survive. Keeps the 31 legitimate in-tree links (Electron Versions/Current, Postgres dylib chain).
desktop/scripts/stage-runtime.mjs Drop *.ts/*.mts/*.cts/*.map from the staged middleware — the kernel runs compiled JS, so these can never load. Removes 24,090 files. *.md deliberately kept so LICENSE.md ships.
desktop/buildResources/afterPack.js With no Developer ID, sign ad-hoc rather than not at all — frameworks (Versions/A) → helper apps → outer app, then --verify --deep. Not distributable, but launchable.
.github/workflows/desktop-apps.yml Verify the bundle on every macOS build via --verify --deep; raise fd limit (belt-and-braces); add a dispatch-only notarize input.

Verification

Signing is confirmed working end-to-end. Run 30615837516 is green, and its DMG artifact was inspected directly:

Check Shipped v0.57.0 This branch
codesign --verify --deep --strict ✗ Fatal rc=0
Identifier Electron ai.omadia.desktop
Authority ad-hoc Developer ID Application: byte5 digital media GmbH (3SUCH58XL7) → Apple Root CA
Secure timestamp none present
Sealed Resources none version=2 rules=13 files=19864
syspolicy_check Fatal codesign error only Notary Ticket Missing
DMG size 314 MB 270 MB

Unit-level checks against the committed code: the escaping-symlink prune was tested with a fixture covering every case including the CI-only one (an absolute link whose target exists outside the tree) — 3 pruned, 3 kept, second pass 0, link targets never touched; the ballast prune was tested on a copy of node_modules/@azure + typescript — 4606 → 1164 files, all .js/.json/.node/.wasm intact.

⚠️ Blocker outside this PR — Apple agreement expired

Notarization fails with:

HTTP status code: 403. A required agreement is missing or has expired.

Reproduced outside CI with the same key, so it is an account-level block, not a workflow or credential problem. The byte5 Account Holder must accept the pending agreement at https://developer.apple.com/account (or App Store Connect → Business → Agreements). No code change will fix it, and no release should be cut until it is resolved — the notarize input exists only to validate signing meanwhile and must never be used for a release.

The six APPLE_*_HIGH5 secrets are now set on this repo (they previously existed only on byte5ai/omadia-ui).

Test plan

  • Signing path green, DMG carries a valid Developer ID signature
  • Always-on structural verification gate passes
  • Apple agreement accepted → notarization succeeds
  • Re-run Desktop apps with notarization on; spctl --assess and stapler validate pass
  • Rebuild and re-upload the v0.57.0 DMG — the asset currently on the release page is the broken one

Weegy added 8 commits July 31, 2026 08:52
The published macOS app cannot be launched at all. Gatekeeper — and Apple's
own `syspolicy_check` — reject it with a Fatal error:

    Code has no resources but signature indicates they must be present.

Confirmed against the shipped v0.57.0 DMG (and v0.56.0, identically broken).

Two independent defects combine to produce it:

1. No Developer ID is configured, so electron-builder skips its signing step
   entirely ("skipped macOS application code signing … 0 identities found").
   The bundle ships with no `Contents/_CodeSignature` at all, while the
   Electron binary inside still carries its linker-signed ad-hoc signature.
   macOS reads that combination as corrupt and refuses to launch it.

2. `stage-runtime.mjs` copies node_modules with `cpSync({dereference:true})`,
   which does not materialise npm's `node_modules/.bin/*` links. They survive
   as ABSOLUTE symlinks into the build machine's checkout
   (`/Users/runner/work/omadia/omadia/...`) and dangle everywhere else — 58 of
   them in the shipped bundle. Nothing execs them, but `codesign` walks the
   tree, hits the first dead link and aborts with "No such file or directory",
   so signing could not have succeeded even once a certificate was added.

Fixes:

- stage-runtime: prune dangling symlinks from the whole staged tree as the
  final step, then re-scan and fail the build if any survive.
- afterPack: when no Developer ID is available, sign AD-HOC instead of not at
  all — nested frameworks (`Versions/A`) and helper apps first, then the outer
  app, since inner signatures seal into their parent. An ad-hoc build is still
  not distributable, but it is structurally valid and therefore launchable.
- desktop-apps.yml: verify the bundle on EVERY macOS build, signed or not.
  Uses `--verify --deep`, because a plain `--strict` passes even when a nested
  framework was never sealed — the exact state that shipped.

Verified against the real v0.57.0 bundle: prune removes exactly 58 dead links
and is idempotent; after signing in the corrected order `codesign --verify
--deep --strict` returns 0 and syspolicy_check's Fatal codesign error is gone,
leaving only the expected "ad-hoc / no notary ticket" distribution warnings.

Distribution still requires the six APPLE_*_HIGH5 secrets (present on
byte5ai/omadia-ui, absent here) to sign with the byte5 Developer ID and
notarize.
Now that a Developer ID is configured, electron-builder actually signs the
app — which means hashing every file in the bundle (~44k, since the staged
middleware ships its full node_modules as extraResources). The macOS runner's
default soft limit of 256 file descriptors is far too low and packaging dies
partway through:

    EMFILE: too many open files, open '.../@azure/msal-browser/src/...'

This could not surface before: with no certificate electron-builder skipped
signing entirely and never opened those files.
…gn error

65536 was not enough — packaging still died with EMFILE. Take the hard limit
instead, and run electron-builder with DEBUG so the underlying codesign
failure is visible: it retries four times and reports only the last error,
which masks the actual cause.
Signing hashes every file in the bundle. The staged middleware ships its full
node_modules as unpacked extraResources (~34k files), and macOS caps concurrent
open files at kern.maxfilesperproc REGARDLESS of ulimit -n, so electron-builder
died with EMFILE and the app could not be signed at all.

Raising the fd limit does not fix it — the previous attempt set it to the hard
max and still failed; on macOS 'unlimited' actually lowers the effective
ceiling. Cutting the file count does fix it.

The kernel runs compiled JS (middleware/dist/index.js), so TypeScript sources,
declarations and source maps can never be loaded at runtime. Pruning *.ts,
*.mts, *.cts and *.map removes ~18.3k of ~34k files. *.md is deliberately kept
so LICENSE.md and friends still ship.

Verified against a copy of middleware/node_modules/@Azure + typescript:
4606 -> 1164 files, all .js/.json/.node/.wasm intact, zero ballast left.
…ngling ones

The previous sweep tested 'does the target exist?', which prunes nothing on CI
and is why signing still failed: on the build machine the 58
node_modules/.bin/* links still resolve (their targets are right there under
/Users/runner/work/...), they just point OUTSIDE the .app. codesign rejects
that with:

    invalid destination for symbolic link in bundle

The same links dangle on every other machine, where codesign instead aborts
with 'No such file or directory'. One cause, two symptoms, and the existence
check only ever caught the second — the build log shows 'pruned 0 dangling
symlink(s)'.

Test the right property: does the target stay INSIDE the staged tree.
realpathSync throws on dangling links and fully resolves absolute or
../-escaping ones, so a single predicate covers every case.

The 31 legitimate in-tree links are kept: Electron's Versions/Current
framework layout and the Postgres engine's relative dylib chain.

Verified with a fixture covering all cases, including the CI-only one (an
absolute link whose target exists outside the tree): 3 pruned, 3 kept, second
pass 0, and link targets outside the tree are never touched.
…andalone

Notarization requires an in-effect Apple Developer Program agreement. byte5's
has lapsed, so notarytool returns:

    HTTP 403: A required agreement is missing or has expired.

Reproduced outside CI with the same key, so it is an account-level block, not
a workflow or credential problem. It fails the whole build even though signing
itself now succeeds.

The input lets us validate the signing path while the agreement is pending.
The release event and workflow_call leave it undefined and therefore always
notarize, so a real release can never silently skip it.
electron-builder auto-notarizes as soon as APPLE_API_KEY/_ID/_ISSUER are in
the environment, so leaving EB_NOTARIZE empty still notarized and the run hit
the same Apple 403.
Keeps the log readable; the comment records how to re-enable it.
@Weegy
Weegy merged commit 8271c7f into main Jul 31, 2026
10 checks passed
Weegy added a commit that referenced this pull request Jul 31, 2026
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.

1 participant