EAS Update (OTA): Shipping JS Changes Without a Play Review
Default posture (2026-07-28): OTA unless a native rebuild is actually required
Every mobile release is OTA-only by default. A native
mobile-android-release.yml build only runs when
packages/tools/scripts/detect-mobile-native-change.js — the guard
described below — actually detects a native-sensitive change. This flips the
previous default (every release triggered the full internal->alpha->beta
native chain) to: ship JS continuously via OTA, and only pay for a native
build when the diff genuinely needs one.
Summary
expo-updates (added 2026-07-28) lets JS/asset-only mobile changes reach
installed app binaries directly, without going through
mobile-android-release.yml or a Play Store review. It does not replace
fastlane — fastlane still builds and submits every .aab exactly as before.
OTA only republishes the JS bundle that an already-shipped binary fetches at
runtime. The two systems are orthogonal and can be adopted without touching
each other.
This is unrelated to the existing config/update-policy.json /
hooks/useNativeUpdateCheck.ts mechanism, which is a native version gate
(blocks/prompts users on an outdated Play binary). OTA is a JS bundle
delivery mechanism. They answer different questions and don't conflict.
What becomes OTA-eligible vs. what still needs a native release
OTA-eligible (JS/TS/asset changes — the vast majority of day-to-day commits):
screens, components, hooks, business logic, styles, copy, most lib/ and
config/*.ts changes, shared @hashpass/* workspace packages consumed by the
mobile bundle (auth, backend, config, emails, types, ui, utils).
Still requires a real mobile-android-release.yml build:
- Any native module add/upgrade, or a
package.jsondependency change in general (a version bump can pull in a new native module transitively). app.json/app.config.js/eas.json/plugins/**/android/**.- New permissions, icon/splash, Expo SDK upgrade.
.github/workflows/mobile-eas-update.yml enforces this boundary with a
positive path allowlist (not paths-ignore — GitHub Actions doesn't allow
combining both on one trigger, and an allowlist is safer by default: a new
native-sensitive file added later doesn't silently start matching). See the
comment block at the top of that workflow for the exact list and reasoning.
That allowlist is the coarse, static first pass; the guard described next is
the precise, dynamic decision that actually gates both what ships as OTA and
what triggers a native build.
Automatic native-change guard
packages/tools/scripts/detect-mobile-native-change.js is the single source
of truth both release workflows defer to. It diffs two git refs and reports
whether anything under these paths changed:
apps/mobile-app/android/**,apps/mobile-app/ios/**,apps/mobile-app/plugins/**,apps/mobile-app/fastlane/**— any diff here always counts.apps/mobile-app/app.config.js,apps/mobile-app/eas.json,apps/mobile-app/fingerprint.config.js,apps/mobile-app/Gemfile[.lock],apps/mobile-app/config/google-services.json,apps/mobile-app/config/amplifyconfiguration.json,apps/mobile-app/react-native.config.js— same, any diff counts.patches/**(root) —pnpm.patchedDependenciesin rootpackage.jsonpatches native RN/Expo packages in place (react-native,react-native-svg,react-native-screens,@react-navigation/drawer, etc). A patch edit changes native behavior without necessarily touchingapps/mobile-app/package.json's own dependency versions, so this needs its own check — confirmed against real history:v1.8.265shipped a@react-navigation/drawerpatch with no other dependency orapp.jsonchange, and would have been wrongly classified OTA-safe without this rule.mobile-android-release.yml's own Gradle cache key already hashespatches/*/*.patchalongsidepnpm-lock.yamlfor the same reason.apps/mobile-app/assets/images/icon.png,apps/mobile-app/assets/images/adaptive-icon.png— referenced directly byapp.json(expo.icon,expo.android.adaptiveIcon.foregroundImage) and baked into native launcher resources at prebuild time.app.json's own structural diff (below) only catches the referenced path changing, not the image bytes at that same path changing — and OTA cannot update an already-installed app's launcher icon, so this needs an explicit file check too.package.json(root) andapps/mobile-app/package.json— structurally diffed on justdependencies/devDependencies, ignoring everything else. Raw whole-file diffing would be useless here: the release version bump touches"version"in both files on literally every release (seeversioning.config.json'ssyncFiles), so a naive diff would always report "changed."apps/mobile-app/app.json— same idea: structurally diffed ignoringexpo.versionandexpo.android.versionCode, sincepackages/tools/scripts/update-version.mjsrewrites both of those on every release too. Any other change to this file (permissions, plugins list,android/iosblocks) still counts.
It's invoked in two different modes, at two different granularities:
--tag <newTag>(release granularity) — used bymobile-release-on-tag.yml. Diffs the new tag against the previousv*.*.*tag. No previous tag (first-ever release) defaults toneedsNative: true— can't verify safety, and for an actual release the worse failure mode is skipping a native build that was needed, not the reverse.--from <sha> --to <sha>(single-push granularity) — used bymobile-eas-update.yml. Diffs one push's commit range (github.event.before->github.sha). An unresolvable range (new branch's first push, force-push, the null SHA000...000) defaults to skipping the OTA publish rather than guessing — for a single push the safe default is "don't publish this one," since the very next resolvable push will catch it, whereas guessing could ship something unverified.
Both modes share the same detectBetweenRefs() core, so the two workflows
can never disagree about what counts as "native" — there's exactly one
definition, applied at two different scopes.
Known limitation: pnpm-lock.yaml is intentionally not diffed. A
dependency bump that changes only the lockfile's resolved transitive
versions (without touching apps/mobile-app/package.json's own
dependencies/devDependencies entries) won't be caught. This is
considered rare enough not to be worth the complexity of lockfile parsing
today — flagged here so it isn't mistaken for an oversight later.
Forcing a native release despite the guard
Re-run .github/workflows/mobile-release-on-tag.yml manually
(workflow_dispatch) with force_native_release=true to skip the diff check
entirely and dispatch mobile-android-release.yml as if native changes were
found — useful for a deliberate store-visibility release, or if you don't
trust the guard's verdict for one specific release. There's no equivalent
force-publish override on the OTA side: if the guard skips an OTA publish for
a push, that push's JS ships on the next release instead (either the next
resolvable OTA-eligible push, or folded into whatever native build ships
next) rather than being force-published somewhere unsafe.
Channel model: environment, not track
The OTA channel is bound to which API backend a binary points at
(environment=development -> api-dev.hashpass.tech,
environment=production -> api.hashpass.tech — see the "Native Android App
Environment" section of the root CLAUDE.md), not to the Play track.
Internal, alpha, and beta all run environment=development and all subscribe
to the "development" channel; only the production track subscribes to
"production". This is deliberate: crossing the two would silently repoint
one environment's installed base at the other environment's JS bundle with no
store review to catch it, which is exactly the failure mode a bad channel
mapping would produce.
Channel-to-branch mapping in mobile-eas-update.yml:
| Push to | Channel | Why |
|---|---|---|
develop | development | Every environment=development binary (internal/alpha/beta) is actually built from a tagged main commit, but runtimeVersion matching is fingerprint-based (see below), not tied to which ref built it or to the marketing version string. develop gets many commits/day, so this is the continuous, "ship in minutes" lane. |
main | production | Only fires as often as the protected develop -> main promotion PR merges (direct pushes to main are rejected), matching the existing deliberately-conservative cadence of the production track itself. |
runtimeVersion: fingerprint policy, not appVersion (corrected 2026-07-28)
This was originally set to runtimeVersion: { policy: "appVersion" }, which
is wrong for this repo and was caught before any binary shipped with it.
appVersion policy ties OTA matching to the exact marketing version string
(expo.version). But packages/tools/scripts/update-version.mjs bumps that
string — and expo.android.versionCode — on every single release,
whether or not anything native changed (see versioning.config.json's
syncFiles). Under appVersion policy, the very act of cutting a release
changes the runtimeVersion a fresh OTA publish would be tagged with, so it
would stop matching whatever's actually installed on already-shipped
binaries the moment a second release happened — breaking continuous OTA
starting from release #2, every time.
Fixed by switching to runtimeVersion: { policy: "fingerprint" } plus
apps/mobile-app/fingerprint.config.js:
module.exports = {
sourceSkips: ['ExpoConfigVersions'], // skip expo.version + expo.android.versionCode
};
fingerprint policy (backed by @expo/fingerprint, already a transitive
dependency of expo-updates 0.28.18) computes runtimeVersion as a content
hash over the native-relevant parts of the project — dependencies, app
config, plugins, native project files — instead of the marketing version.
ExpoConfigVersions tells it to ignore exactly the two fields that get
auto-bumped every release, so the hash only changes when something
actually native-relevant changes. Verified locally against this repo:
- Bumping only
expo.version+expo.android.versionCode→ identical fingerprint hash. - Adding a new Android permission → different hash, as expected.
- Changing an arbitrary
extra.*value (e.g.EXPO_PUBLIC_SUPABASE_URL,extra.eas.projectId) → no effect either way —@expo/fingerprintdoesn't track theextraconfig section at all, confirmed empirically, so environment-specific values baked in viabuildExpoConfig()never cause spurious fingerprint churn between the production and development build profiles.
This also confirmed the project's Expo "workflow" resolves to managed
(apps/mobile-app/android/ is gitignored, not committed — see
resolveWorkflowAsync in expo-updates/utils/build/workflow.js), which
matters because fingerprint is the only runtimeVersion policy Expo
supports outside the managed workflow — the others throw if the workflow
resolves to generic (bare). Confirming managed here means we weren't
relying on that fallback; fingerprint was simply the correct choice on its
own merits.
First release after adopting OTA needs one real native build
v1.8.273 — the version live on Play as of 2026-07-28 — was built and
submitted before expo-updates existed in this repo. That binary has no
update-checking capability at all; OTA cannot reach it under any
runtimeVersion policy, because the native module that talks to u.expo.dev
simply isn't compiled into it. The next release is necessarily the one that
bakes expo-updates into the native binary for the first time — there's no
way to retrofit that onto an already-shipped build.
This doesn't need special-casing in the release flow: the native-change
guard (detect-mobile-native-change.js) already forces needsNative: true
for this exact release, for two independent reasons found in the diff
against v1.8.273 — apps/mobile-app/package.json gained the
expo-updates dependency (dependency-diff check), and apps/mobile-app/app.json
gained the runtimeVersion key (structural diff check, ignoring only the
version/versionCode fields). So the guard sends this release through the
normal full native path (internal → alpha → beta → manual production
dispatch) automatically, exactly as it should.
Once that binary reaches production and real users have updated to it, every subsequent release that doesn't touch anything native-sensitive publishes OTA-only (per the guard) and reaches those installed binaries correctly, because their embedded fingerprint and the fingerprint computed at each later OTA-publish time stay identical across any number of pure version bumps — that's the whole point of the fix above.
How the channel gets embedded without eas build
Fastlane builds this app via expo prebuild + local Gradle, not eas build.
EAS Build normally bakes the channel into the binary for you at build time;
a non-EAS-Build pipeline has to do it explicitly. buildExpoConfig() in
apps/mobile-app/lib/eas-config.js sets:
updates: {
url: `https://u.expo.dev/${projectId}`,
requestHeaders: { 'expo-channel-name': updateChannel },
}
resolveUpdateChannel() (same file) picks production for the production
EAS build profile and development otherwise, reusing the exact
profile-resolution convention resolveProjectId/resolveExpoToken already
use. expo prebuild writes this into AndroidManifest.xml, so it applies
identically whether the build runs via fastlane or eas build.
apps/mobile-app/eas.json's "channel" field on the production/preview
build profiles mirrors this for documentation and for the eas build path
(android:release:eas* scripts) — it does nothing for the fastlane path,
which is why the real enforcement lives in eas-config.js, not eas.json.
Publishing
In-app update lifecycle
The app uses the standard mobile OTA lifecycle rather than silently replacing JavaScript during an active user flow:
expo-updateschecks at native startup.- The application also checks when it returns to the foreground, throttled to once every five minutes, and downloads a compatible update in the background.
- Once downloaded, the app shows Restart to update. The user chooses when to reload, preventing a meeting request, pass scan, or form from being lost.
- The Version Information drawer shows whether the running bundle is embedded or OTA-fetched, its EAS channel, update ID, publish time, and a manual Check for OTA updates / Restart to apply downloaded update action.
This gives support both device-level traceability and a safe recovery path. Sentry receives the update ID, runtime version, channel, and embedded-launch state on every native launch for rollout/adoption monitoring.
Version drawer checks: Play Store versus OTA
The mobile Version drawer exposes two independent checks:
- Check for Play Store updates calls
/api/config/versionsand compares the installed native version withnativeVersion. A newer native artifact opens the platform store URL (using the Androidmarket://link first and the web URL as a fallback). This check does not treat an OTA-onlycurrentVersionas a store release. - Check for OTA updates asks
useOtaUpdateto check the active EAS channel. It reports checking, downloading, ready-to-restart, and retryable error states. A downloaded update is not applied while the user is in the middle of a flow; choose Restart or relaunch the app to apply it.
The Version Information details view also reports whether the running bundle is embedded or OTA-fetched, its runtime version, channel, update ID, and publish time. When diagnosing a stale screen, first run the relevant check, then fully close and reopen the app before comparing the displayed build. The Play Store check and OTA check can legitimately report different states: the former concerns a native binary and the latter concerns the JavaScript bundle loaded by an already-installed binary.
cd apps/mobile-app
npm run ota:publish # -> production channel, last commit message
npm run ota:publish:dev # -> development channel
Both reuse packages/tools/scripts/run-mobile-eas.js — the same
EXPO_TOKEN/EXPO_TOKEN_DEV/EAS_PROJECT_ID/EAS_PROJECT_ID_DEV secrets
and vars already configured for the android:release:eas* backend, no new
credentials needed.
eas update rejects --branch and --channel together (fixed
2026-07-28, caught by CI failing on the real workflow run): the scripts
originally passed --branch <name> --channel <name> --auto, which eas update refuses outright — --channel, --branch, and --auto are
mutually exclusive selectors, not composable flags, confirmed against the
installed eas-cli@20.1.0's own --help and runtime validation. Fixed to
pass only --channel <name> (letting eas auto-create the backing branch
of the same name on first use — verified live against the real EAS project)
plus an explicit --message derived from the last commit, dropping
--branch/--auto entirely. Also added --platform android: without it,
eas update builds and publishes an iOS bundle too on every run, which this
repo never ships (no ios/ directory, no iOS store URLs configured) — pure
wasted export/upload time.
Local-only gotcha: the EXPO_TOKEN_DEV value in the repo-root .env file
may not have eas update/eas channel:* permissions, even though the
value configured as the EXPO_TOKEN_DEV GitHub secret does. Verified while
diagnosing the flag issue above: running eas whoami/eas project:info
locally with the .env value for EXPO_TOKEN_DEV succeeds and correctly
resolves to @hashpasstechs-team, but eas channel:list/eas update with
that same local token fail with Entity not authorized: AppEntity[...] (action = READ) — most likely because that particular token was generated
with a limited "Builds and Submissions"-only scope in the Expo dashboard.
This is local-environment-specific, not a CI problem: the real
mobile-eas-update.yml CI run against develop published to the
development channel successfully (confirmed 2026-07-28, update group
f6e03fea-0802-4b0b-a92f-cfe65799b5c9), so the EXPO_TOKEN_DEV GitHub
secret is a different, correctly-scoped token already. If you need to run
npm run ota:publish:dev locally, regenerate the token in the Expo
dashboard (Account Settings → Access Tokens, under the hashpasstechs-team
account) with EAS Update permissions and update your local .env — no
action needed against the GitHub secret unless CI itself starts failing
with this same error.
CI: .github/workflows/mobile-eas-update.yml runs these automatically on
push (see the path allowlist above), subject to the native-change guard
skipping it if the push also touched something native-sensitive (see
above), and can be dispatched manually with a channel override and a
custom message — manual dispatch bypasses the guard entirely, trusting the
operator's explicit choice. It runs on a plain ubuntu-latest GitHub-hosted
runner — no EC2, no Android SDK, no Ruby/fastlane — since publishing an
update only needs to export the JS bundle, not build native code. It does
still need the eas CLI itself (run-mobile-eas.js shells out to it) —
the workflow installs it with the same pinned npm install -g eas-cli@20.1.0
step mobile-android-release.yml uses, since a plain pnpm install only
installs workspace dependencies, not a global eas binary.
Verifying an OTA update actually applied (added 2026-07-29)
Two things are easy to conflate and both showed up in a real "did OTA work?" investigation: whether the update published successfully, and whether a given installed app has fetched and applied it. They're checked differently.
Did it publish? Check the mobile-eas-update.yml run for the push in
question (gh run list --repo hashpass-tech/hashpass.tech --workflow mobile-eas-update.yml) — the "Publish OTA update" step logs the branch,
runtime version (a fingerprint hash, not a version string), platform,
update group ID, and the triggering commit directly:
Branch production
Runtime version a10bb450181f5a948c9619e0abdce3d1dfcffc3f
Platform android
Update group ID 04b3d69a-b84f-450f-aef2-3ecf51d68004
Commit a654bc855...
If that run shows Skipping OTA publish instead, the guard found a
native-sensitive change in that push — see "Automatic native-change guard"
above.
Did the installed app pick it up? This app has no custom
Updates.checkForUpdateAsync()/fetchUpdateAsync() code and
app.json's expo.updates is {} (all defaults), so it relies entirely
on expo-updates' built-in behavior: check silently on every app launch,
download in the background if a compatible update exists, but only
apply it on the next cold start — not the session that downloaded it.
A user who publishes an update and then checks their already-running app
without a full force-close + reopen will see no change and reasonably
suspect the update didn't work, when it's actually just waiting for a
relaunch.
The in-app "Update Status (OTA)" section (Version Details modal, right
below Build Information — added 2026-07-29, VersionDetailsModal.tsx)
answers this directly, reading expo-updates' own Updates.isEmbeddedLaunch
/ Updates.channel / Updates.updateId / Updates.createdAt: "Native
build bundle (no OTA update applied)" means the app is still running its
originally-installed JS, "Fetched OTA update" means the currently-running
bundle came from a real EAS Update publish, along with its channel and
update ID so it can be cross-referenced against the publish log above.
Force-close and reopen the app, then check this section, before concluding
an OTA push didn't take effect.
Rollback
cd apps/mobile-app
EAS_BUILD_PROFILE=production node ../../packages/tools/scripts/run-mobile-eas.js update:republish --channel production
Interactively lists recent updates on that channel and republishes a prior
one instantly — no new build, no store review. Swap
EAS_BUILD_PROFILE=preview ... --channel development for the development
channel.
Known limitation: no self-hosted update server yet
updates.url currently points at Expo's hosted u.expo.dev service (free up
to 1,000 MAU, usage-priced beyond that). expo-updates speaks a documented,
open manifest protocol, so migrating to a self-hosted server later (e.g. on
the same AWS account already used for api.hashpass.tech) is a config-only
change — swap updates.url in buildExpoConfig() — not a client rewrite.
Not needed today; noted here so it isn't re-litigated as a blocker if EAS
Update's usage pricing ever becomes a concern.