Electron Stagewright docs

ADR-012: Production validation plugin

Status: Accepted. Current checks cover bundle structure, Info.plist fields, URL scheme declarations, the packaged updater feed, the crash-reporter machinery, code signing, notarization (xcrun stapler validate), and Gatekeeper.

Context

Electron's sharpest production pain is distribution: an app that runs fine in development fails on a user's machine because it is unsigned, not notarized, or its bundle is malformed — and the failure is opaque. Stagewright drives running apps; nothing inspects the build artifact. The production validation plugin closes that: given a packaged .app, report — structured — whether it is production-ready.

The acceptance criteria carry one subtle requirement: distinguish missing evidence from failed evidence. "I checked and the signature is invalid" and "I could not check (no toolchain here)" are different answers; collapsing them produces false confidence or false alarms.

Decision

1. A three-valued evidence model

Every check returns status: 'pass' | 'fail' | 'unknown':

The tool returns { ok, app_path, passed, summary: { pass, fail, unknown }, checks }. The envelope ok is true whenever validation RAN; the app's verdict is passed (no fail). unknown checks do not flip passed, but summary discloses them so a green-with-skips result is never mistaken for full verification. Only a bad input (no app at appPath) is a tool error (production.APP_NOT_FOUND / production.NOT_A_BUNDLE) — a failed CHECK is data, not an error, matching the AC's "return structured failures".

2. Shell out to the toolchain, not into app code

The checks invoke the macOS toolchain (codesign --verify --deep --strict, spctl --assess, xcrun stapler validate, plutil -convert json) rather than evaluating app JavaScript. So the plugin needs no --allow-eval and no running session — it inspects a path on disk. Every spawn is timeout-bounded via a shared runCommand (execFile + timeout + capped output) that never rejects; a command-not-found or timeout becomes spawnError, which the checks map to unknown. There is deliberately no platform branch: on a non-macOS host the tools are simply absent (ENOENT → unknown), which also lets tests drive every branch through a fake runCommand on any OS.

3. macOS first; bundle structure stays dependency-free

macOS is the first-class target. The bundle-structure check is pure filesystem (Info.plist + Contents/MacOS/ executable present), so it runs anywhere and needs no plist parser. The notarization check uses xcrun stapler validate to confirm a ticket is stapled to the bundle (offline, so a non-zero exit is a real fail, never unknown); a pass reads the spctl source= line as best-effort evidence. The info-plist check shells out to plutil -convert json (which reads both XML and binary plists) and verifies CFBundleIdentifier (reverse-DNS), CFBundleShortVersionString, and a CFBundleExecutable that exists under Contents/MacOS/. The protocol-schemes check reads the same plist and validates every CFBundleURLTypes entry (RFC-3986 scheme shape, no duplicates across entries, no shadowing of well-known system schemes); declaring no schemes is an affirmative pass. The updater-feed check is pure filesystem: a packaged Contents/Resources/app-update.yml (electron-updater) must declare a provider with its per-provider required fields and https URLs — an ABSENT file is unknown, because the built-in autoUpdater sets its feed at runtime, which a static scan cannot see. The crash-reporter check is pure filesystem: the crashpad handler must ship intact (and executable) under Electron Framework.framework/Versions/<v>/Helpers/; a missing framework is unknown (not an Electron-shaped bundle), while a present framework whose handler is missing or lost its execute bit is a fail — packaging silently disabled crash capture.

Alternatives considered

Consequences

References