fix(desktop): macOS installer ships an unopenable, unsigned app bundle - #558
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The published macOS app cannot be launched at all. Gatekeeper — and Apple's own
syspolicy_check— reject it with a Fatal error:Reproduced against the shipped
v0.57.0DMG;v0.56.0is 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.appcarries nocom.apple.quarantinexattr, so Gatekeeper never evaluated it and local runs masked the defect. Diagnostic tell:/Applications/omadia.appwas 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 noContents/_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.
codesigncould not have succeeded anyway — symlinks escaping the bundle.stage-runtime.mjscopies node_modules withcpSync({dereference:true}), which does not materialise npm'snode_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:.appinvalid destination for symbolic link in bundleNo such file or directoryTesting "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 ofulimit -n— so electron-builder died withEMFILE. Raising the fd limit does not help; setting it tounlimitedactively 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.frameworkunsealed and macOS still reports the same Fatal error. Notecodesign --verify --strictpasses on that state — only--deepcatches it.Changes
desktop/scripts/stage-runtime.mjsrealpathSynccovers dangling, absolute and../-escaping in one predicate); re-scan and fail the build if any survive. Keeps the 31 legitimate in-tree links (ElectronVersions/Current, Postgres dylib chain).desktop/scripts/stage-runtime.mjs*.ts/*.mts/*.cts/*.mapfrom the staged middleware — the kernel runs compiled JS, so these can never load. Removes 24,090 files.*.mddeliberately kept soLICENSE.mdships.desktop/buildResources/afterPack.jsVersions/A) → helper apps → outer app, then--verify --deep. Not distributable, but launchable..github/workflows/desktop-apps.yml--verify --deep; raise fd limit (belt-and-braces); add a dispatch-onlynotarizeinput.Verification
Signing is confirmed working end-to-end. Run 30615837516 is green, and its DMG artifact was inspected directly:
codesign --verify --deep --strictElectronai.omadia.desktopnoneversion=2 rules=13 files=19864syspolicy_checkNotary Ticket MissingUnit-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/.wasmintact.Notarization fails with:
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
notarizeinput exists only to validate signing meanwhile and must never be used for a release.The six
APPLE_*_HIGH5secrets are now set on this repo (they previously existed only onbyte5ai/omadia-ui).Test plan
Desktop appswith notarization on;spctl --assessandstapler validatepass