The app ships JS and assets over the air with 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

Both run scripts/ota-push.sh <profile> [message]:
  1. Reject any profile other than production or preview.
  2. Build a default message, OTA <profile> <UTC timestamp>, when none is given.
  3. Run node scripts/sync-eas-ota-env.js <profile>.
  4. Run eas update --branch <profile> --environment <profile> --message "<message>".

Environment sync

An OTA bundle is built on your machine, but eas 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:
  1. Refuses to run if app.json has uncommitted changes.
  2. For each runtime, rewrites expo.version in app.json and runs pnpm ota:production "<message> (runtime <version>)".
  3. Restores app.json with git checkout -- app.json in a trap on EXIT, INT and TERM.
The default list in the script is 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.
This script deliberately defeats the runtime version guard. It sends today’s JS to binaries built weeks or months ago. Everything in the next section applies.

The native-module runtime trap

The appVersion 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

  1. A new native dependency is never imported statically from shared code until every live runtime has it.
  2. Load it behind requireOptionalNativeModule or a guarded dynamic import, and make the feature degrade.
  3. If JS depends on a native fix instead of a new module, gate it on the installed version with runtimeIsAtLeast().
  4. Remove a runtime from the publish list once its binaries cannot run current JS safely, instead of guarding forever.

Pre-publish checklist

Before running ota-all-runtimes.sh:
  • List native dependencies added since the oldest runtime in the list. Compare package.json against the commit each store build was cut from.
  • Search for every import of each one and confirm it is guarded.
  • Check app.json plugin, permission and entitlement changes. A bundle cannot rely on a permission the old binary never declared.
  • Check modules/ and patches/. JS that assumes patched native behaviour needs a version gate.
  • Publish to preview first 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:
Look for the newest update on the reporter’s runtime version, and for a rollback row that no later publish has replaced.

Rollback

This tells devices to drop back to the JS embedded in their binary. A rollback applies per runtime, and it stays in force until a newer update is published for that same runtime. Rolling back as a precaution and then publishing a fix only to the current runtime leaves the rolled-back runtimes on their embedded JS, which can be months old. After a rollback, publish the fixed bundle to each runtime that was rolled back.

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:
  1. Build and submit the new binary.
  2. Add the previous version to the default list in ota-all-runtimes.sh.
  3. Update the server’s latest-version setting so the app update prompt starts asking older binaries to update.