Publish a React Native OTA Release
Build a JavaScript bundle, publish it to a deployment, and verify that installed apps actually picked the update up.
Publish
Publish a release
Run this from your React Native project root. It detects Expo vs bare React Native, runs the right bundler for each configured platform, zips the output with its assets, and uploads it:
npm run ota:staging # ota release --channel staging
npm run ota:prod # ota release --channel production
ota release --channel production --rollout 20 --platform iosNothing is positional — no app name, no key, no version. The project comes from ota.config.js, the runtime version from your app version, and the release note, commit, branch and author from git. Every flag is optional:
| Flag | Default | Meaning |
|---|---|---|
-c, --channel | staging | Which channel to publish to. |
-p, --platform | both | Limit to android or ios. |
-m, --message | last commit subject | Release note shown in the dashboard and to the SDK. |
-r, --rollout | 100 | Percentage of devices served, 1–100. |
--mandatory | off | Devices install before continuing, using IMMEDIATE by default. |
--runtime-version | your app version | Which native builds this bundle is compatible with. |
--allow-dirty | off | Publish with uncommitted changes in the working tree. |
--allow-duplicate | off | Publish even though the bundle is byte-identical to the current release. |
What a publish prints:
$ ota release --channel production -m "Fix checkout crash"
✔ Released #14 to production
android 4.2 MB
ios 4.4 MB
commit 9f3c1ab (main)#14 is the release label — the same value the SDK reports as getCurrentPackage().label, so a device can always be matched to a release.
A dirty working tree is refused
--allow-dirty and accept that the release is recorded as dirty.Get the runtime version right
--runtime-version (the device-side SDK reports the same value as targetBinaryVersion). Get it wrong and no device ever receives the release, which is the most common cause of "I published but nothing happened".Three commands cover the whole job
ota init wrote the scripts so nothing here has to be memorised: npm run ota:prod ships, npm run ota:doctor says why it didn't, and npm run ota:rollback undoes it. Flags go after --(npm run ota:prod -- --rollout 10), and yarn and pnpm need no -- at all. The raw commands in the CLI reference are what these scripts run.Promote instead of rebuilding
Once staging looks healthy, serve the exact same bytes from production. The stored bundle is reused, so there is no bundler run and no upload:
npm run ota:promote # staging → production
ota promote --from staging --to productionConfirm
Verify it worked
Change something visible, publish, then background and cold-start the app. The default install mode is ON_NEXT_RESTART, so a foreground refresh will not apply it.
# Android — watch the native state machine
adb logcat | grep OtaUpdate
# Expected on a successful update:
# I OtaUpdate: booting into pending update <sha256>
# I OtaUpdate: update <sha256> confirmed healthyOn iOS, open Console.app and filter on OtaUpdate.
Server side, confirm the device reported in:
npm run ota:doctor # when nothing arrives at all
ota status # the last ten releases and what each is doingOr open the channel in the dashboard, where each release shows how many devices are running it, the install and failure counts, and what share of that platform’s active fleet has moved — Android and iOS separately, so a healthy number on one cannot hide a stalled one on the other.
Testing rollback without writing a crash
Remove withOtaUpdate (or simply never call notifyAppReady()), publish, let the device install it, then relaunch twice. The second launch reverts to the previous bundle and blacklists the bad hash — you will see failed to become ready — rolling back in the log and a rolled_back report in the dashboard.