v4.25 — Release-Build Safety Gates
CI now assembles and boots a minified/release-mode mobile build before merge — on both platforms — so a crash that only reproduces under R8/optimization can't ship undetected again.
Summary
Before this milestone, a production release-only startup crash had already shipped once: v4.3.0's Android build force-closed on launch because R8 minification stripped WorkManager's reflective WorkDatabase_Impl.<init> (OBJ-1899). The root cause was structural, not a one-off miss — every pre-merge mobile CI job built debug artifacts only, and the jobs that did assemble release/minified artifacts (assembleRelease, the tag-only fastlane iOS archive) only assembled or uploaded them, never booting the result. This entire class of bug — crashes and registration failures that only reproduce in an optimized build — was invisible to CI regardless of test count or quality, on either platform.
This milestone closed that gap with three pre-merge CI gates: a Capacitor native-registration drift gate (catching an npm-installed plugin that ships without its native registration regenerated — the same shape as OBJ-1899's companion drift), an Android release build-and-boot smoke test, and an iOS release build-and-boot smoke test paired with a separate plugin-registration check (a boot smoke can't catch a missing registration — it fails as UNIMPLEMENTED at call time, not a crash). All three shipped as independent, additive GitHub Actions workflows off a shared integration branch, with a fourth phase documenting them. The iOS registration check proved its own premise on landing: it caught a real live bug, WidgetBridgePlugin compiled into the App target but never registered with the Capacitor bridge.
The whole milestone is infrastructure-only — new .github/workflows/*.yml files and CI-side build config under ionic_frontend/. No Rails or Vue application code changed, and rollback for any gate is simply deleting or disabling its workflow.
Goal
Close the structural CI gap that let the OBJ-1899 crash ship: add pre-merge gates that assemble a minified/release-mode mobile build and boot it far enough to confirm no immediate startup crash, on both Android and iOS, plus a
cap syncdrift gate so an npm-installed Capacitor plugin can't silently ship without its native registration. A boot-and-check-no-crash smoke — not full E2E — is enough to have caught this bug class.
Scope — What Shipped
.github/workflows/cap-sync-drift.yml— on any PR (or push tomaster) touchingionic_frontend/package.json,package-lock.json,android/**, orios/**, runs a freshnpx cap sync android && npx cap sync iosand diffs the result against the 3 git-tracked, cap-sync-generated files (android/capacitor.settings.gradle,android/app/capacitor.build.gradle,ios/App/CapApp-SPM/Package.swift). Deliberately triggers on the npm manifests, not just native paths — the bug class it targets can land with zero native-file changes..github/workflows/mobile-android-boot-smoke.yml— aproguard-keep-rules-guardjob greppingproguard-rules.profor the WorkManager/Room keep rules, plus aboot-smokejob that assembles a minified:app:assembleRelease(debug-signed), verifies an R8mapping.txtwas actually produced, and boots the APK on areactivecircus/android-emulator-runneremulator, failing on a startup crash or ANR within a 10-second window..github/workflows/mobile-ios-boot-smoke.yml— two independent jobs:boot-smoke(3a) builds the App target in Release for an iOS Simulator (CODE_SIGNING_ALLOWED=NO), verifies the build wasn't de-optimized to pass (SWIFT_OPTIMIZATION_LEVELmust not resolve to-Onone), then boots and asserts no startup crash;plugin-registration-check(3b) runsios/App/AppTests/PluginRegistrationTests.swift, which reflects over the App target's compiled classes plus the npm-sidepackageClassListand asserts every one of them actually registers with the live Capacitor bridge.- A same-milestone live bug fix:
WidgetBridgePlugincompiled into the iOS App target but never registered — closed by addingbridge?.registerPluginInstance(WidgetBridgePlugin())toMainViewController.capacitorDidLoad(). - Documentation:
docs/operations/mobile-builds.md's "Release-Build Safety Gates (v4.25)" section (all three gates — what each catches, how it works, local-reproduction commands), a newCONTRIBUTING.md§ Capacitor Plugin Installs contributor rule, and aCHANGELOG.md[Unreleased]entry.
Phases
| Phase | Name | Status | Plans | Highlights |
|---|---|---|---|---|
| 1 | cap sync drift gate | Shipped | 1 | New cap-sync-drift.yml; triggers on npm manifest changes, not just native paths — closing the exact no-native-diff shape OBJ-1899's companion drift shipped with |
| 2 | Android release build & boot smoke | Shipped | 1 | Minified :app:assembleRelease + emulator boot + ProGuard keep-rule guard; needed an Orion respec to a single hardened script after 3 fix-rounds |
| 3 | iOS release boot smoke + plugin-registration check | Shipped | 1 | Two separate gates (3a boot smoke, 3b registration reflection test); 3b caught a real live bug on landing — WidgetBridgePlugin unregistered |
| 4 | Documentation | Shipped | 1 | mobile-builds.md section, CONTRIBUTING.md rule, CHANGELOG.md entry; shipped via the milestone-merge PR after a wrong-base-ref false start |
Key Decisions
- Three separate CI gates instead of one combined check — the milestone's own root-cause analysis found two structurally distinct failure modes that a single check couldn't cover: (1) native-registration drift from an un-synced npm plugin install, invisible without diffing generated files against the dependency graph; (2) a release-only crash, which only a real boot-and-wait can catch; (3) a release-only registration gap, which surfaces as
UNIMPLEMENTEDat call time and is invisible to a boot smoke because the app doesn't crash — it just silently fails the specific call. Folding (2) and (3) into one iOS gate was explicitly rejected in the ROADMAP's locked decisions: a boot smoke proves the app started, not that every plugin it depends on actually works. - The drift gate triggers on
package.json/package-lock.json, not native paths only — a native-only filter would have made the gate a no-op on the exact bug class it targets: PR #1542 (which added@capacitor/app-launcher) touched 38 files and zero underandroid/orios/. The boot-smoke gates (Phases 2–3) were allowed to keep a native-only path filter as a cost mitigation, since they don't share that blind spot. - Boot smoke, not full E2E — assemble minified/release → install → launch → assert the process is still alive and no crash dialog appeared, for a fixed window. Full E2E was explicitly scoped out; a boot-and-check-no-crash smoke is sufficient to have caught OBJ-1899.
- The
WidgetBridgePluginfix that 3b's own build validated its premise — the plugin-registration check wasn't a speculative gate against a hypothetical future regression; it caught a live, already-shipped bug (WidgetBridgePlugincompiled but never callingregisterPluginInstance) during the same phase that built the check. That's the strongest possible proof the check earns its place: it went red on a real defect, not a synthetic one. - Additive-only, no fix-first phase — the ROADMAP's baseline scoping found the WorkManager/Room ProGuard keep rules (PR #1877) were already present on
masterby kickoff, so this milestone added CI gates only; it never touched product code, and rollback for any phase is deleting or disabling its workflow.
Requirements Coverage
4 / 4 deliverables satisfied (quoted from the MILESTONE-AUDIT). This is a CI/infra-only milestone with no backend/API/data-model surface, so deliverables are tracked by the ROADMAP's four phase IDs (OBJ-1902–1905) rather than a numbered REQUIREMENTS.md.
| Category | Count | Status |
|---|---|---|
| OBJ-1902 (Phase 1 — drift gate) | 1 | Satisfied |
| OBJ-1903 (Phase 2 — Android boot smoke) | 1 | Satisfied |
| OBJ-1904 (Phase 3 — iOS boot smoke + registration check) | 1 | Satisfied |
| OBJ-1905 (Phase 4 — docs) | 1 | Satisfied |
Full detail: v4.25-release-build-safety-gates-MILESTONE-AUDIT.md on GitHub.
Outcomes
A PR that reintroduces the OBJ-1899 bug class — a minified/release build that crashes on startup, or a Capacitor plugin that ships without native registration — now fails CI before merge, on both Android and iOS, instead of reaching production undetected. The three gates are proven against the failure modes they target: the drift gate is designed around the exact no-native-diff shape the original bug shipped with, the boot smokes exercise the real R8/optimization codepath (not a debug build), and the registration check already caught one live defect during its own construction. No production tag had been cut for this milestone as of ship — it rides the next scheduled Wednesday release train per normal (non-hotfix) cadence, but the gates themselves are live on master and protecting every PR from the moment they merged.
Tech Debt
None open. One candidate — OBJ-1906, docs debt split out of Phase 1 at ship time — was checked against master during the retroactive audit and appears fully covered by Phase 4's documentation (docs/operations/mobile-builds.md, CONTRIBUTING.md); see the MILESTONE-AUDIT for the caveat on that finding.
Related Artifacts
- Roadmap: v4.25-release-build-safety-gates-ROADMAP.md
- Milestone Audit: v4.25-release-build-safety-gates-MILESTONE-AUDIT.md
- Docs: Mobile Builds — Release-Build Safety Gates (v4.25)
- Origin incident: OBJ-1899 / PR #1877 — the R8/WorkManager release-only startup crash this milestone prevents recurrence of
- Merge PRs: #1885, #1888, #1889, #1893
Related Commits
30cddb29— [Orion] v4.25: Release-Build Safety Gates — milestone merge to master (#1893)7ac2c0b9— [Tess] ci(mobile-ios): add Release boot smoke + plugin-registration gate (OBJ-1904) (#1889)86d9cf1b— [Tess] ci(mobile-android): add minified release build & boot smoke gate (OBJ-1903) (#1888)ca6a7e72— [Tess] ci(cap-sync): add native drift gate (OBJ-1902) (#1885)
Last updated: 2026-08-04