Skip to content
My Guidelines
Guides
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. runtimeVersion MUST 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.

BackendMobile
RollbackRedeploy the previous imageUsers already have the broken version installed
Delivery latencySecondsHours to days (store review)
Version fragmentationOne version in productionEvery version you ever shipped, simultaneously
GatekeeperNoneApple 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 allowed
Command CodeSign failed with a nonzero exit code
** BUILD FAILED ** (xcodebuild exit 65)

macOS attaches extended attributes to everything under ~/Library/Mobile Documents/:

AttributeEffect
com.apple.FinderInfoBreaks codesign
com.apple.fileprovider.dir#N, …fpfs#PiCloud File Provider metadata
com.apple.provenanceHarmless

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:

PathResult
~/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.

eas.json
{
"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:

ProfilePurposeWho installs it
developmentDev client with the debug bundleDevelopers
previewRelease build, internal distributionQA, stakeholders
productionStore buildEnd 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.

NumberMeaningWho reads it
version (1.4.0)Marketing version, semverUsers, store listings
buildNumber / versionCodeMonotonic build counterThe stores, uniqueness only
runtimeVersionNative-layer compatibility contractEAS 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.

app.config.ts
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 changesNative code or native dependencies
Images, fonts, other bundled assetsExpo SDK upgrades
Copy, styling, layoutPermissions, entitlements, app icon, name
Bug fixes in JS logicAnything 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 gates
merge to main → preview build + Maestro E2E on both platforms
tag v* → production build → submit → OTA channel promotion

Rules:

  • 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.

SignalToolThreshold
Crash-free sessionsSentryThe headline metric. Alert on any drop after a release.
ANRs (Android)Play ConsoleGoogle penalises ranking above their bad-behaviour threshold
OTA adoption rateEAS UpdateConfirms an update actually reached devices
Cold startCustom metricRegressions here are felt immediately
Store reviewsBoth consolesCrash 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 thingRecoveryTime
JS bug, runtimeVersion unchangedRepublish the previous update to the channelMinutes
Native bug, or runtimeVersion changedNew build → review → releaseDays
Catastrophic releaseHalt store rollout, expedited review requestHours 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 / DropboxPlain local path
Manual runtimeVersion bumpspolicy: 'fingerprint'
OTA to ship a feature that needs native codeNew binary
OTA used to bypass store reviewHotfix and iteration only
OTA to 100% immediatelyGradual rollout with crash-free monitoring
Same bundle identifier for all variantsOne per variant
Signing credentials in the repoEAS Secrets
Production build from an untagged commitTagged release only
Release build without SentryCrash reporting from day one
Tracking crash countCrash-free sessions
Assuming users updateThe API supports every version in the wild
Privacy labels filled in from memoryAudited against actual data collection

11. Pre-release checklist

  • Project is on a plain local path, not inside iCloud Drive or any sync client
  • runtimeVersion policy is fingerprint
  • 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