In this guide (15 items)
Mobile Release
Mobile Release
MANDATORY FOR ALL AI AGENTS: OTA updates ship JavaScript only. Any change to native code, native dependencies or app configuration REQUIRES a new binary — pushing it as an OTA update produces a crash loop on existing installs.
runtimeVersionMUST change whenever the native layer changes. Signing credentials and store API keys live in EAS Secrets, NEVER in the repository. Never ship a release build without crash reporting enabled.
Companion command: /mobile-init-project — generates eas.json with the three build profiles and the runtimeVersion fingerprint policy of §4.
This is the mobile counterpart of gradle.md and github-actions.md: how the artifact is built, versioned and delivered. Framework context: react-native.md.
1. Why this is different from backend delivery
A backend rollback is a redeploy. A mobile rollback is not.
| Backend | Mobile | |
|---|---|---|
| Rollback | Redeploy the previous image | Users already have the broken version installed |
| Delivery latency | Seconds | Hours to days (store review) |
| Version fragmentation | One version in production | Every version you ever shipped, simultaneously |
| Gatekeeper | None | Apple and Google review |
The consequence shapes everything: your API must support every app version still in the wild. A user who never updates keeps calling last year’s endpoints. Breaking changes follow the versioning and deprecation rules of api-design.md — with far longer sunset windows than a web client needs.
2. Where the project lives
⚠️ An iOS project cannot be built from inside iCloud Drive. Codesigning fails:
<Framework>.framework: resource fork, Finder information, or similar detritus not allowedCommand CodeSign failed with a nonzero exit code** BUILD FAILED ** (xcodebuild exit 65)macOS attaches extended attributes to everything under ~/Library/Mobile Documents/:
| Attribute | Effect |
|---|---|
com.apple.FinderInfo | Breaks codesign |
com.apple.fileprovider.dir#N, …fpfs#P | iCloud File Provider metadata |
com.apple.provenance | Harmless |
xattr -cr does not fix it — it fails with Permission denied, because the iCloud
File Provider owns those attributes while the files live in its container.
A .nosync suffix does not help. It stops iCloud uploading the directory; it does not
stop the File Provider stamping metadata on it. The failure is codesigning, not syncing.
Same commit, same toolchain, verified 2026-08-25 on Expo SDK 57 / Xcode 26.6:
| Path | Result |
|---|---|
~/Library/Mobile Documents/com~apple~CloudDocs/… | BUILD FAILED, exit 65 |
~/local-dev/… | Build OK, app running, 240s |
Keep mobile projects on a plain local path. Watch for symlinks: a directory that looks
local can resolve into the iCloud container — check with readlink before assuming.
Use git, not file sync, as the backup.
The same class of failure comes from Dropbox, Google Drive and any other file-provider sync client. It is not specific to iCloud.
3. EAS Build
Builds run on EAS infrastructure. iOS builds require no Mac — this is the practical reason Expo is the default in react-native.md.
{ "build": { "development": { "developmentClient": true, "distribution": "internal", "env": { "APP_VARIANT": "development" } }, "preview": { "distribution": "internal", "env": { "APP_VARIANT": "preview" } }, "production": { "autoIncrement": true, "env": { "APP_VARIANT": "production" } } }, "submit": { "production": {} }}Three profiles, always:
| Profile | Purpose | Who installs it |
|---|---|---|
development | Dev client with the debug bundle | Developers |
preview | Release build, internal distribution | QA, stakeholders |
production | Store build | End users |
Each variant uses a different bundle identifier, so all three coexist on one device. Set this up before the first build; changing a bundle identifier later means every existing install is orphaned.
autoIncrement on production so the build number never collides — a duplicate build number is rejected at submission, after the build has already run.
4. Versioning
Two independent numbers, and confusing them causes real incidents.
| Number | Meaning | Who reads it |
|---|---|---|
version (1.4.0) | Marketing version, semver | Users, store listings |
buildNumber / versionCode | Monotonic build counter | The stores, uniqueness only |
runtimeVersion | Native-layer compatibility contract | EAS Update |
runtimeVersion is the one that matters
An OTA update is only delivered to binaries whose runtimeVersion matches. It is the guard that prevents shipping JavaScript that calls a native module the installed binary does not contain.
runtimeVersion: { policy: 'fingerprint' }The fingerprint policy computes it from the actual native project, so it changes automatically when the native layer changes. Use it. Manual policies rely on a human remembering — and the failure mode of forgetting is a crash loop on every user’s device, fixable only by a store release.
⚠️ Change the native layer, change runtimeVersion. Native code, a new native dependency, an Expo SDK upgrade, or an app.config.ts change touching native config all qualify.
5. OTA updates — EAS Update
Ships a new JS bundle and assets without store review. Powerful and dangerous in equal measure.
What an OTA update can and cannot do
| ✅ Can | ❌ Cannot |
|---|---|
| JavaScript and TypeScript changes | Native code or native dependencies |
| Images, fonts, other bundled assets | Expo SDK upgrades |
| Copy, styling, layout | Permissions, entitlements, app icon, name |
| Bug fixes in JS logic | Anything requiring a runtimeVersion change |
⚠️ Pushing an update that needs new native code to a binary that lacks it produces a startup crash on every device that receives it. Users cannot fix it; they uninstall. runtimeVersion: fingerprint exists to make this impossible.
Store policy limits
Apple permits bug fixes and minor changes over the air. It does not permit changing the app’s core purpose or functionality — the reviewed binary must remain representative of what users run. Google’s policy is comparable.
Treat OTA as hotfix and iteration, not as a way to avoid review. Using it to ship unreviewed features risks removal, which costs far more than a review cycle.
Channels
Map channel → branch: production, preview, development. Deployment is promoting a branch to a channel — and rollback is republishing the previous update to that channel, which is the fastest recovery available in mobile.
Roll out gradually. A bad OTA reaches 100% of users in minutes, faster than any store release. Start at a fraction, watch crash-free sessions, then promote.
6. CI/CD
Same gate philosophy as github-actions.md: everything that can fail the build, does.
PR → typecheck, lint, boundary rules, unit + component tests, coverage gatesmerge to main → preview build + Maestro E2E on both platformstag v* → production build → submit → OTA channel promotionRules:
- Never build production from anything but a tagged commit on the release branch.
- Store an artifact and a changelog per release. When a crash report names version 1.4.2, you need to reproduce exactly that build.
- Credentials in EAS Secrets, never in the repo, never in
eas.json. See mobile-security.md. - E2E runs against a real build, not a simulator-only debug bundle.
7. Store submission
eas submit handles upload. What it does not handle is what causes rejections.
Pre-submission checklist:
- Privacy labels (App Store) and Data Safety (Play) match what the app actually collects
- iOS Privacy Manifest (
PrivacyInfo.xcprivacy) present and accurate - Every permission has a usage description explaining a real user benefit
- Demo account provided if any content is behind login — a reviewer who cannot log in rejects
- Screenshots for all required device sizes
- Age rating, category, support URL, privacy policy URL
- Account deletion path exists and is reachable in-app (required by both stores)
- No debug menus, placeholder copy or test data in the release build
Common rejections, in order of frequency: missing or wrong privacy declarations, unexplained permissions, a reviewer unable to log in, crashes on the reviewer’s device, and payment flows that bypass in-app purchase where the stores require it.
Plan for a review cycle of days, not hours. Ship features earlier than feels necessary.
8. Post-release monitoring
Shipping is not the finish line — it is the point where the metric starts.
| Signal | Tool | Threshold |
|---|---|---|
| Crash-free sessions | Sentry | The headline metric. Alert on any drop after a release. |
| ANRs (Android) | Play Console | Google penalises ranking above their bad-behaviour threshold |
| OTA adoption rate | EAS Update | Confirms an update actually reached devices |
| Cold start | Custom metric | Regressions here are felt immediately |
| Store reviews | Both consoles | Crash reports arrive as one-star reviews before they arrive as tickets |
Crash-free sessions, not crash count. Count scales with usage and tells you nothing about severity.
Source maps upload on every build, or stack traces are unreadable minified noise.
Watch the first hours after a release with rollout still partial. That is the window where rollback is cheap.
9. Rollback
Know the path before you need it:
| Broken thing | Recovery | Time |
|---|---|---|
JS bug, runtimeVersion unchanged | Republish the previous update to the channel | Minutes |
Native bug, or runtimeVersion changed | New build → review → release | Days |
| Catastrophic release | Halt store rollout, expedited review request | Hours to days |
This asymmetry is the argument for gradual rollout and for keeping native changes in their own, carefully reviewed releases.
10. Anti-patterns
| ❌ | ✅ |
|---|---|
| iOS project inside iCloud Drive / Dropbox | Plain local path |
Manual runtimeVersion bumps | policy: 'fingerprint' |
| OTA to ship a feature that needs native code | New binary |
| OTA used to bypass store review | Hotfix and iteration only |
| OTA to 100% immediately | Gradual rollout with crash-free monitoring |
| Same bundle identifier for all variants | One per variant |
| Signing credentials in the repo | EAS Secrets |
| Production build from an untagged commit | Tagged release only |
| Release build without Sentry | Crash reporting from day one |
| Tracking crash count | Crash-free sessions |
| Assuming users update | The API supports every version in the wild |
| Privacy labels filled in from memory | Audited against actual data collection |
11. Pre-release checklist
- Project is on a plain local path, not inside iCloud Drive or any sync client
-
runtimeVersionpolicy isfingerprint - Native layer unchanged → OTA is safe; changed → new binary
- Version and build number incremented correctly
- Full CI suite green, E2E passing on both platforms
- Sentry enabled, source maps uploaded
- No debug menus, placeholder copy or test data in the build
- Store metadata, privacy declarations and permissions audited
- Demo account works for reviewers
- Account deletion reachable in-app
- Gradual rollout configured, rollback path known
- Backend still serves every app version in the wild