expo-ota-guard
v0.1.1
Published
Answers one question before you publish an Expo OTA update: is this change safe to ship over-the-air, and to which live runtimes?
Maintainers
Readme
expo-ota-guard
Is this change safe to ship over the air, and to which live runtime?
A zero-config CLI that reads your pending changes and tells you, before you run eas update, whether the update will actually reach your users or quietly fail to.
npx expo-ota-guardInspected 26 changed files since 0c5126d~1
Native-affecting changes
- package.json:@stripe/stripe-react-native [dependency-added]
@stripe/[email protected] was added; @stripe/stripe-react-native ships
native code: android/, ios/, podspec, config-plugin
hint: This needs a new native build before the change can reach users.
- app.json:plugins [config-native-field]
plugins changed: config plugins run during prebuild and mutate the native projects
hint: Run a new build (eas build) before shipping this; an OTA cannot change it.
- android/app/build.gradle [native-directory]
lives in a native project directory, which is compiled into the binary
OTA-safe changes (22)
- src/services/videoTrim.ts
is a source or asset file that ships inside the JS bundle
...
Runtime targeting
- This update would target runtime 1.0.7
UNSAFE TO OTA This change alters the native layer. Ship a new build; an OTA would not deliver it.
7 native-affecting, 0 unclear, 22 OTA-safeWhy this exists
Three failure modes cost real teams real days. This tool exists to catch each one.
1. expo-updates fails silently when an OTA lands on a mismatched runtime.
You add a native dependency, change a config plugin, or edit a native field in app.json, then publish an OTA. The JS ships. The native code does not. The app crashes on the new code path, or the feature quietly no-ops, and nothing in the publish output warned you. expo-ota-guard classifies every pending change and refuses to call the update safe when the native layer moved.
2. Nobody knows which runtimeVersion is actually live.
eas build:list only shows builds EAS made. Builds produced with eas build --local or in your own CI never appear there, so the list you trust is missing the binaries your users are actually running. eas update:list is the only source of truth, because every published update records the runtime it targeted. expo-ota-guard runtimes reads the truthful source and says so out loud.
3. Your fix looks broken because you tested a stale bundle.
With updates.fallbackToCacheTimeout: 0 and checkAutomatically: ON_LOAD (the common setup), the app launches the cached bundle immediately, downloads the new one in the background, and only runs it on the next cold start. So the first relaunch after publishing still shows the old code. Developers conclude the fix did not work, and ship another one. expo-ota-guard doctor names this behaviour explicitly for your config.
Quickstart
# Should I OTA what I have right now?
npx expo-ota-guard
# Everything since main, not just the working tree
npx expo-ota-guard --since origin/main
# Which runtimes are actually live?
npx expo-ota-guard runtimes
# Audit the updates config for known traps
npx expo-ota-guard doctor
# Gate CI before "eas update" (exits 1 when unsafe)
npx expo-ota-guard --ciCommands
| Command | What it does |
|---|---|
| (default) | Full verdict: classifies changes and reports live runtimes from EAS |
| check | Classification only, no network, no eas-cli |
| runtimes | Which runtimeVersions are live per branch, channel and platform |
| doctor | Audits expo-updates config for the traps above |
Options
| Flag | Meaning |
|---|---|
| --since <ref> | Classify <ref>..HEAD plus uncommitted work (default: working tree vs HEAD) |
| --branch <name> | Limit runtime discovery to one EAS branch |
| --cwd <dir> | Project directory (default: current directory) |
| --ci | JSON output, exit 1 when the change is not OTA-safe |
| --no-eas | Skip eas-cli entirely (offline, faster) |
| --no-color | Disable coloured output |
Exit codes
| Code | Meaning |
|---|---|
| 0 | Safe to OTA |
| 1 | Unsafe, or needs review, or doctor found an error |
| 2 | Usage error, or no Expo project / git repo found |
What counts as native-affecting
The classifier is the core of the tool, and it explains every call it makes rather than just flagging a file.
Dependencies. Each added, removed or upgraded entry in dependencies is looked up in your node_modules and judged by what is actually installed: an android/ or ios/ directory, a .podspec, a build.gradle, an expo-module.config.json, a config plugin (app.plugin.js or an expo.plugin manifest field), or a cpp/ directory. devDependencies and peerDependencies are treated as JS-only, because they never ship in the bundle. Removing a native package is also flagged: the installed binary still links it until you rebuild.
App config. Changes to app.json / app.config.json are diffed field by field. plugins, android.*, ios.*, runtimeVersion, newArchEnabled, jsEngine, icon, splash, permissions, entitlements and the whole updates.* block are native. extra, version, web and other metadata are JS-only. Unrecognised fields are reported as needing review rather than silently assumed safe.
Native directories. Anything under android/ or ios/ is native, full stop.
Everything else (source files, assets, lockfiles) ships in the bundle and is safe.
CI usage
- name: Guard the OTA
run: npx expo-ota-guard --ci --since ${{ github.event.before }}
- name: Publish update
run: eas update --branch production --autoThe --ci run exits non-zero on an unsafe change, so the publish step never runs when the change needed a build instead.
Limitations
Worth knowing before you trust it blindly:
- Dynamic configs are not executed.
app.config.js/app.config.tsare reported as "needs review" rather than diffed, because knowing their output means running your code. Comparenpx expo config --type publicbefore and after if you need certainty. - Nativeness is judged from
node_modules. If dependencies are not installed, the tool falls back to a curated list of well-known native packages and marks anything else as unclear rather than guessing. - A package can be native without looking native. A pure-JS package that reaches into a native module installed by something else will read as safe. Rare, but possible.
runtimesneeds an authenticated eas-cli. Without it, runtime discovery is skipped and the classification still runs. Use--no-easto skip it deliberately.- Channel and branch mismatches are reported, not diagnosed.
runtimesshows which channel serves which branch, so you can see when a channel namedproductionactually points at a branch namedmain. It does not yet warn you that publishing to a same-named branch would reach nobody. Read the channel column before you publish. - The
nativeVersionruntime policy resolves per platform at build time, so a single target runtime cannot be reported for it statically. - This is a static analyser, not a build. It reasons about what changed, not about whether your code is correct.
Design notes
- No network calls of its own. The only outbound work is shelling out to
gitandeas-cli. - No telemetry, no analytics, no phoning home.
- Ships as plain Node ESM and runs on Node >= 18 with a single runtime dependency (
picocolors). - The classifier is pure and table-driven: it takes file contents in and returns findings out, which is why it is covered by fixture-based unit tests rather than integration mocks.
Development
bun install
bun test # table-driven tests, including real eas-cli payload fixtures
bun run typecheck
bun run buildLicense
MIT
