ADR-0003: Update policies — silent, opt-in, forced, critical
- Status: Accepted
- Date: 2026-09-11
- Issue: #62 (D3 update policies; the grill outcome is the last comment on the issue). Pipeline half: #137.
Context
The OTA rungs of the ladder (PLAN.md decisions 3, 9) publish to staging on every merge and promote the same update group to uat and production, but the installed app only ever ran expo-updates' native default: check on launch, download in the background, apply on the next cold start. useUpdatePolicy was a stub (manual) behind the Updates screen's buttons. Missing were a way to say "this update must land now" (a bad-data hotfix), a way for testers to always run the newest group without relaunching, an opt-in prompt for production users, staged production rollouts, and a rule for the very common case of an app that is never cold-started for days.
Decision
- Default policy =
silent. expo-updates' native behaviour (check on launch, download in the background, apply on the next process start) plus the idle-resume rule below. No UI. forcedis per update, not per build. A publish-time variable (EAS_UPDATE_CRITICAL=1, neverEXPO_PUBLIC_) makesapp.config.tswriteextra.updatePolicy: 'forced'into the update manifest. The running app reads that field from the incoming update's manifest on a successful check (checkForUpdateAsync().manifest.extra.expoClient.extra.updatePolicy) and, when set, downloads and reloads immediately (Sentry flushed first). Republish / promote carries the flag unchanged.- Idle-resume reload. When the app returns to the foreground after ≥
RESUME_RELOAD_AFTER_MS(30 min, a code constant next to the hook) in the background and an update is already downloaded, reload into it. Applies to every policy. A check also runs on every foreground, not only on launch. - Staged rollouts.
rollout_percentageinput onpromote.yml(default 100, honoured only fortarget=production, passed toeas update:republish --rollout-percentage). Ramp-up isrollout.yml(EASupdate-rolloutjob behind an approval, #145), witheas update:editas the CLI fallback; the runbook documents ending a bad rollout (eas update:rollback). UAT is always 100. (#137) - App-level policy from env.
EXPO_PUBLIC_UPDATE_POLICYin the Zod schema:silent(default) |opt-in|forced. Set per EAS environment; recommendedforcedonpreview(staging / UAT testers always run the newest) andsilentonproduction. Build-levelforcedmeans every downloaded update reloads immediately. - Opt-in UI. A non-blocking top banner ("Update ready" — Restart now / Later) built with the project's component and i18n conventions,
testIDs on both buttons. "Later" hides it until the next downloaded update; idle resume still applies. - Single mount. The policy driver runs once from the root layout (
src/app/_layout.tsx); screens never call expo-updates.useUpdatePolicystays the only place that changes behaviour,useUpdateInfostays read-only. The Updates screen keeps its manual check / download buttons as the test bed and shows the active policy. - Delivery = two PRs. PR 1 (#62): the app side — hook, env var, banner, root mount, Updates screen policy row,
app.config.tsextra, Jest tests with mocked expo-updates, docs, this record. PR 2 (#137): the pipeline side —rollout_percentageandcriticalworkflow inputs, runbook sections including how to verify each policy on staging. - Verification. Unit tests only in CI; real forced / rollout checks need a staging build with updates enabled (owner-owed, paid). The docs describe the manual check.
Consequences
- The reload on idle resume is app-initiated: expo-updates never restarts the app by itself, so the driver calls
reloadAsync()from theAppStateactivetransition. Anything the user was doing in a resumed session is lost at that moment; the 30-minute floor is what makes that acceptable, and a project that keeps long-lived unsaved state should raise the constant. - Verifying
forced, the critical flag and rollouts needs a staging build with updates enabled on a device — a paid EAS build and an owner-owed step; Jest only proves the decision logic against a mockedexpo-updates. extrahad to leave the native fingerprint (SourceSkips.ExpoConfigExtraSectioninfingerprint.config.js): a critical publish changed the iOS hash (24e465…→901742…), so it would have computed a runtime version no build matches and reached nobody. The skip is safe —extraships in the manifest, not in native code — and moves the hash once (fe0fc6…), i.e. one round of staging builds on the merge that introduces it.EXPO_PUBLIC_UPDATE_POLICYis a new optional EAS variable; unset meanssilent, so nothing changes for existing environments until the owner setsforcedonpreview.- The
manualpolicy is gone: the Updates screen's buttons remain, but they are now a test bed on top of whichever policy is active rather than a policy of their own. - A critical publish is a manual
EAS_UPDATE_CRITICAL=1 bun run eas update …until #137 adds thecriticalworkflow input;rollout_percentagedoes not exist until then either.