OTA Updates
System Intent
- What this is: Expo EAS Update integration that enables the encache mobile app to receive JavaScript and asset updates without app store resubmission. Updates are distributed through Expo's CDN to devices subscribed to one of three independent channels (development, staging, production). The app checks for updates on every launch and applies them automatically before the user sees the UI.
Mermaid Diagram
graph TB
A["Developer / CI"] -->|"eas update --channel <channel>"| B["EAS Update Service\n(Expo CDN)"]
subgraph "Configuration"
F["app.json\nupdates.url, checkAutomatically"]
G["eas.json\nchannels + build profiles"]
end
F -->|"configures"| D
G -->|"configures"| B
B -->|"update bundle available"| D
subgraph "Mobile App (index.tsx)"
D["Root component\n__DEV__ guard"] -->|"Updates.checkForUpdateAsync()"| E["expo-updates library"]
E -->|"isAvailable=true"| H["Updates.fetchUpdateAsync()"]
H -->|"success"| I["Updates.reloadAsync()"]
E -->|"isAvailable=false or error"| J["SplashScreen.hideAsync()\nrender App normally"]
I -->|"JS engine reloads"| D
end
K["publish-update.sh"] -->|"validates git clean\nruns eas update"| B
L[".github/workflows/mobile-release.yml"] -->|"push to main → staging\npush to release/* → production"| B
Flows
Flow: initializeOTAConfig
- Test files:
apps/mobile/tests/ota-config.test.ts - Core files:
apps/mobile/app.json, apps/mobile/eas.json, apps/mobile/package.json
Types
AppJsonUpdates {
enabled: boolean (must be true)
checkAutomatically: "ON_LOAD" | "ON_ERROR_RECOVERY" | "NEVER"
fallbackToCacheTimeout: number (ms; set to 30000)
url: string (https://u.expo.dev/<PROJECT_ID>)
}
EasJsonUpdate {
enabled: boolean
channels: string[] (["development", "staging", "production"])
}
EasJsonBuild {
development: { developmentClient: true, distribution: "internal" }
preview: { distribution: "internal" }
production: { distribution: "store" }
}
Paths
| path | input | output | path-type | notes |
initializeOTAConfig.valid | app.json + eas.json + package.json | configuration accepted by eas-cli | happy path | expo-updates plugin registered in app.json plugins array |
initializeOTAConfig.missingUrl | app.json without updates.url | CI validation step fails | error | Caught by mobile-release.yml node validation step |
initializeOTAConfig.missingChannels | eas.json with <2 channels | CI validation step fails | error | Caught by mobile-release.yml node validation step |
Flow: publishUpdate
- Test files:
apps/mobile/tests/ota-publish.test.ts - Core files:
apps/mobile/scripts/publish-update.sh, .github/workflows/mobile-release.yml
Types
PublishInput {
channel: "development" | "staging" | "production" (default: "staging")
message: string (default: "Automated OTA update")
}
PublishOutput {
updateId: string (uuid assigned by EAS)
publishedAt: ISO8601 timestamp
channel: string
commitHash: string (short SHA captured at publish time)
branch: string
}
PublishError {
cause: "dirty-working-tree" | "auth-failure" | "bundle-too-large" | "network-failure"
message: string
}
Paths
| path | input | output | path-type | notes |
publishUpdate.success | PublishInput with clean git tree | PublishOutput logged to stdout | happy path | eas update exits 0; devices begin polling |
publishUpdate.dirtyTree | working directory has uncommitted changes | PublishError cause=dirty-working-tree, exit 1 | error | Script checks git status --porcelain before calling eas |
publishUpdate.authFailure | invalid or expired EXPO_TOKEN | PublishError cause=auth-failure | error | Re-authenticate via eas login or rotate EXPO_TOKEN secret |
publishUpdate.bundleTooLarge | bundle > 50 MB | PublishError cause=bundle-too-large | error | Implement code splitting or remove unused assets |
Pseudocode
FUNCTION publishUpdate(channel, message):
IF git status --porcelain is non-empty:
PRINT "Uncommitted changes"
EXIT 1
commit = git rev-parse --short HEAD
branch = git rev-parse --abbrev-ref HEAD
RUN: eas update --channel channel --message message
IF exit code != 0:
PRINT "Failed to publish to " + channel
EXIT 1
PRINT success summary (channel, message, branch, commit)
CI triggers (mobile-release.yml): - push to main → eas update --channel staging - push to release/* → eas update --channel production - workflow_dispatch → user-specified channel and message
Flow: appReceivesUpdate
- Test files:
apps/mobile/tests/ota-update-apply.test.ts - Core files:
apps/mobile/index.tsx
Types
UpdateCheckResult {
isAvailable: boolean
}
UpdateError {
message: string (non-fatal; app continues with current bundle)
}
Paths
| path | input | output | path-type | notes |
appReceivesUpdate.updateAvailable | production app launches, new bundle on CDN | bundle downloaded, Updates.reloadAsync() called, UI renders new code | happy path | Splash screen held visible during download; [OTA] log prefix used throughout |
appReceivesUpdate.upToDate | production app launches, no new bundle | SplashScreen.hideAsync(), App renders with current bundle | happy path | Logged as "[OTA] App is up to date" |
appReceivesUpdate.devMode | __DEV__ === true | update check skipped entirely | happy path | Logged as "[OTA] Running in development mode, skipping update check" |
appReceivesUpdate.checkError | network timeout or EAS unreachable | error caught silently, app continues on current bundle | error | Error is swallowed in catch block; app launch is not blocked |
appReceivesUpdate.fallbackTimeout | update check takes > 30 s | fallbackToCacheTimeout (30000 ms) triggers, cached bundle used | error | Configured in app.json updates.fallbackToCacheTimeout |
Pseudocode
// apps/mobile/index.tsx — Root component useEffect
SplashScreen.preventAutoHideAsync()
async function initializeApp():
TRY:
IF NOT __DEV__:
update = await Updates.checkForUpdateAsync()
IF update.isAvailable:
await Updates.fetchUpdateAsync()
await Updates.reloadAsync() // JS engine reloads; execution stops here
CATCH error:
setUpdateError(error) // ErrorBoundary shows message; app continues
FINALLY:
setIsReady(true)
await SplashScreen.hideAsync()
Flow: rollbackUpdate
- Test files:
apps/mobile/tests/ota-rollback.test.ts - Core files:
apps/mobile/scripts/publish-update.sh, EAS dashboard
Types
RollbackInput {
channel: "staging" | "production"
previousUpdateHash: string (from `eas update list` output)
rollbackMessage: string
}
RollbackOutput {
newCurrentUpdateId: string
devicesCoverage: "95%+ within 5 minutes" (expected SLA)
}
Paths
| path | input | output | path-type | notes |
rollbackUpdate.success | RollbackInput with valid hash | previous bundle published as current; devices revert on next launch | happy path | OTA does not support native rollback; JS/asset only |
rollbackUpdate.hashUnknown | previous hash not recorded | manual investigation required | error | Keep eas update list output in post-deploy notes |
rollbackUpdate.devicesOffline | devices offline during rollback window | affected devices remain on broken version until next launch | error | No mitigation beyond waiting; update applies on reconnect |
Flow: manageChannels
- Test files:
apps/mobile/tests/ota-channels.test.ts - Core files:
apps/mobile/eas.json
Types
Channel: "development" | "staging" | "production"
ChannelConfig {
development: { developmentClient: true, distribution: "internal" }
preview: { distribution: "internal" }
production: { distribution: "store" }
}
UpdateChannels {
enabled: true
channels: ["development", "staging", "production"]
}
Paths
| path | input | output | path-type | notes |
manageChannels.independent | update published to staging | only staging devices receive update; production devices unaffected | happy path | Channels are fully independent in EAS |
manageChannels.promotion | staging validated, publish to production | production devices receive update on next launch | happy path | Recommended: 30+ min staging soak before promotion |
manageChannels.misconfigured | channel name not in eas.json channels array | publish-update.sh exits 1 with "Invalid channel" error | error | Script validates channel against allowed list before calling eas |
Logs
| Source | Location |
| Update check | console.log("[OTA] ...") in apps/mobile/index.tsx at runtime |
| Update error | console.error("[OTA] Error checking for updates:", error) in apps/mobile/index.tsx |
| Publish success | stdout of publish-update.sh or GitHub Actions step logs |
| CI build/publish | .github/workflows/mobile-release.yml job logs in GitHub Actions |
| EAS dashboard | https://expo.dev — per-channel update history and device coverage |
Deployment
- Mechanism:
Expo EAS Update (managed cloud service) - Deploy command:
# Manual publish to a channel
cd apps/mobile
./scripts/publish-update.sh staging "Fix: crash on memory pressure"
# Or directly via eas-cli
eas update --channel production --message "Release v1.2.0"
# Or via GitHub Actions (manual trigger)
gh workflow run mobile-release.yml -f channel=production -f message="Release v1.2.0"
- Notes:
- OTA updates apply to JavaScript and asset changes only; native code changes require a full
eas build and app store submission. app.json updates.url must be set to https://u.expo.dev/<YOUR_PROJECT_ID> before the first publish. The placeholder YOUR_PROJECT_ID must be replaced with the actual EAS project ID. - CI publishes staging automatically on every push to
main; production publishes automatically on every push to release/* branches. - Devices use
checkAutomatically: "ON_LOAD" with a 30-second fallbackToCacheTimeout; if the update check exceeds 30 seconds the cached bundle is used instead. - Rollback is performed by republishing a previous update bundle to the same channel via
eas update.