expo-updates. A store build is only needed when native code changes.
How an update finds a device
Three settings connect a published update to an installed binary.
With the
appVersion policy the runtime version equals expo.version. A binary built as 1.0.7 only accepts updates published with runtime 1.0.7. That is the safety rule: a bundle and a binary with the same runtime version are assumed to have the same native modules.
No file under src/ imports expo-updates. The app relies on the library’s default behaviour and has no in-app update UI. If a downloaded update fails to launch, expo-updates falls back to the bundle embedded in the binary.
Publishing
scripts/ota-push.sh <profile> [message]:
- Reject any profile other than
productionorpreview. - Build a default message,
OTA <profile> <UTC timestamp>, when none is given. - Run
node scripts/sync-eas-ota-env.js <profile>. - Run
eas update --branch <profile> --environment <profile> --message "<message>".
Environment sync
An OTA bundle is built on your machine, buteas update --environment reads variables from the EAS environment, not from eas.json. sync-eas-ota-env.js keeps the two in step so a store build and an OTA bundle for the same profile see the same values.
It reads build.<profile>.env from eas.json, keeps APP_ENV and every EXPO_PUBLIC_* key, and pushes each one with eas env:set. Other keys are logged as skipped, because they only matter to native builds. It exits with an error if the profile has no env block.
EXPO_PUBLIC_* values are compiled into the bundle and are public by design. Never put a secret in one.
Publishing to every runtime
After a version bump, trainees who have not updated from the store are on an older runtime and stop receiving updates published for the new one. To reach them, the same bundle has to be published once per runtime.scripts/ota-all-runtimes.sh:
- Refuses to run if
app.jsonhas uncommitted changes. - For each runtime, rewrites
expo.versioninapp.jsonand runspnpm ota:production "<message> (runtime <version>)". - Restores
app.jsonwithgit checkout -- app.jsonin atraponEXIT,INTandTERM.
1.0.6 1.0.5 1.0.4 1.0.3 1.0.2 1.0.1 1.0.0. It does not include the current version. Publish to the current runtime with pnpm ota:production first, and update the default list after each release.
The restore is a trap on purpose. The script’s own comment explains it: a version left behind in app.json would mis-target both the next OTA and the next store build, and nobody would notice until the wrong users got an update.
The native-module runtime trap
TheappVersion policy promises that a bundle only runs on a binary with matching native code. Publishing one bundle to every runtime breaks that promise. The bundle can now land on a binary that lacks a native module the bundle imports.
What happens then depends on where the import is.
The first row is the dangerous one because it is silent. The fallback means the app still opens, on the JS that shipped inside the binary. Trainees see an old version of the app, and every later OTA fails the same way. Project notes record exactly this: a native module was added to the root layout after the only Android store build, and Android installs stayed on their embedded bundle for weeks while iOS updated normally. It surfaced as an unrelated-looking bug report on one platform.
The current root layout shows the fix.
SystemNavigationBar loads expo-navigation-bar lazily and renders nothing when it is missing.
Rules
- A new native dependency is never imported statically from shared code until every live runtime has it.
- Load it behind
requireOptionalNativeModuleor a guarded dynamic import, and make the feature degrade. - If JS depends on a native fix instead of a new module, gate it on the installed version with
runtimeIsAtLeast(). - Remove a runtime from the publish list once its binaries cannot run current JS safely, instead of guarding forever.
Pre-publish checklist
Before runningota-all-runtimes.sh:
- List native dependencies added since the oldest runtime in the list. Compare
package.jsonagainst the commit each store build was cut from. - Search for every import of each one and confirm it is guarded.
- Check
app.jsonplugin, permission and entitlement changes. A bundle cannot rely on a permission the old binary never declared. - Check
modules/andpatches/. JS that assumes patched native behaviour needs a version gate. - Publish to
previewfirst and open the update on a preview build.
Reading a bug report
When a fix “is on dev but the trainee still sees the bug”, check what the device is running before reading code:Rollback
What can and cannot ship over the air
The home screen widget layouts are written in JS in
src/features/home/widgets, but they run inside the widget extension. Treat changes there as needing a build unless you have confirmed otherwise on a device.
Version bumps
version appears in both package.json and app.json. app.json is the one that matters: it sets the runtime version, the version shown in the stores, and the value Constants.expoConfig.version returns to the health version gate and the app update prompt.
eas.json sets appVersionSource: "remote" and autoIncrement: true on the production profile, so build numbers are managed by EAS. The marketing version is still bumped by hand in app.json.
After a bump:
- Build and submit the new binary.
- Add the previous version to the default list in
ota-all-runtimes.sh. - Update the server’s latest-version setting so the app update prompt starts asking older binaries to update.