Skip to content

Add EAS Update (OTA) to the mobile app - #2074

Open
SawyerHood wants to merge 1 commit into
mainfrom
bb/complete-testflight-submission-thr_tk8aew5isy
Open

Add EAS Update (OTA) to the mobile app#2074
SawyerHood wants to merge 1 commit into
mainfrom
bb/complete-testflight-submission-thr_tk8aew5isy

Conversation

@SawyerHood

Copy link
Copy Markdown
Collaborator

What was wrong

A JS-only fix for the mobile app needed a full EAS build and an App Store Connect upload. That round trip is acceptable for internal testers, who install every nightly. It is expensive for external testers, who wait for processing and, for a real release, for review.

expo-updates was not installed, and the build profiles in eas.json carried no channels.

What changed

  • expo-updates, with updates.url and runtimeVersion.policy: "fingerprint" in app.json, plus a channel per build profile in eas.json.
  • fingerprint.config.js skips ExpoConfigVersions. mobile-ios-eas.yml rewrites app.json version with the npm version on every nightly, and the fingerprint hashes the whole Expo config by default. Without the skip, each nightly forked the runtime version and no update could ever match an installed build.
  • app.config.js turns the update client off when BB_DISABLE_UPDATES=1, and mobile-e2e.yml sets it. The E2E job builds in Release. A Release binary with updates enabled asks the production channel for a bundle at launch, and that bundle would replace the embedded E2E bundle in the middle of a Maestro flow. The failures would look random.
  • mobile-update.yml publishes an update. It is manual: an update reaches every tester in minutes with no review in between. The job prints the runtime fingerprint next to the recent builds' fingerprints, because an update that reaches nobody otherwise looks like a successful publish.
  • README: a new "Over-the-air updates (EAS Update)" section replaces the deferral note.

How verified

Fingerprints, generated with expo-updates fingerprint:generate --platform ios:

case fingerprint
version: 0.0.1, no skip 7f9c90b3…
version: 0.39.0, no skip 0f650d4d…
version: 0.0.1, with skip b162a732…
version: 0.39.0, with skip b162a732…
with skip, plus one extra Info.plist key f9b4c845…

So the marketing version no longer forks the runtime version, and a native change still does.

expo prebuild --platform ios, reading the generated Expo.plist: EXUpdatesEnabled is false with BB_DISABLE_UPDATES=1 and true without it.

pnpm exec turbo run typecheck lint test --filter=@bb/mobile --force: green, 834 tests. The new app.config.test.ts covers both silent failure modes; its E2E case fails when the guard in app.config.js is removed and passes with it.

Note

Build 0.0.1 (2), already processed in TestFlight, carries no update client. The first build made after this merges is the earliest one that can receive an update.

AGENT GENERATED: by Claude Opus 5

The mobile app could only ship a JS fix through a full EAS build and an App
Store Connect upload. That is fine for internal testers, who install every
nightly, but external testers make the round trip expensive.

`expo-updates` was not installed and the build profiles carried no channels.

What changed:

- `expo-updates`, with `updates.url` and `runtimeVersion.policy: "fingerprint"`
  in `app.json`, and a channel per build profile in `eas.json`.
- `fingerprint.config.js` skips `ExpoConfigVersions`. `mobile-ios-eas.yml`
  rewrites `app.json` `version` with the npm version on every nightly, and the
  fingerprint hashes the whole Expo config by default. Without the skip each
  nightly forked the runtime version and no update could ever match an
  installed build. Confirmed: the fingerprint at `0.0.1` and at `0.39.0` is
  `7f9c90b3…` vs `0f650d4d…` without the skip, and `b162a732…` for both with
  it. A native change still forks it (`f9b4c845…` with one extra Info.plist
  key), so an update cannot land on an incompatible binary.
- `app.config.js` turns the update client off when `BB_DISABLE_UPDATES=1`, and
  `mobile-e2e.yml` sets it. The E2E job builds in Release, and a Release binary
  with updates enabled asks the production channel for a bundle at launch,
  which would replace the embedded E2E bundle in the middle of a Maestro flow.
  Verified through prebuild: `EXUpdatesEnabled` is `false` with the variable and
  `true` without it.
- `mobile-update.yml` publishes an update. It is manual, because an update
  reaches every tester in minutes with no review in between. It prints the
  runtime fingerprint next to the recent builds' fingerprints, since an update
  that reaches nobody otherwise looks like a successful publish.
- README: an "Over-the-air updates" section replaces the deferral note.

How verified:

- `pnpm exec turbo run typecheck lint test --filter=@bb/mobile --force`: green,
  834 tests.
- `app.config.test.ts` covers both silent failure modes. The E2E case fails
  when the `BB_DISABLE_UPDATES` guard is removed and passes with it.
- `expo prebuild --platform ios` with and without `BB_DISABLE_UPDATES=1`,
  reading `EXUpdatesEnabled` out of the generated `Expo.plist`.
- Fingerprints generated with `expo-updates fingerprint:generate` for the three
  cases above.

Note: build `0.0.1 (2)`, already in TestFlight, carries no update client. The
first build after this change is the earliest one that can receive an update.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bb-slop-cop

bb-slop-cop Bot commented Aug 20, 2026

Copy link
Copy Markdown

🚨 SLOP COP 🚨 · review

I am SlopCop. I am reviewing this pull request for security, code quality, performance, architecture, and practical behavior.

cancel-in-progress: false

steps:
- name: Checkout repository

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — Production updates can run from an unreviewed Git ref.

A manual workflow lets the operator select the Git ref. This checkout uses that ref, and the job then receives EXPO_TOKEN and publishes immediately. A repository writer can therefore send unmerged code to the production channel. The job also has no protected GitHub environment.

Require refs/heads/main for production. Put the production token in a protected environment with an independent approval rule. Keep branch-based runs only for preview if that behavior is intentional.

if [[ -z "$message" ]]; then
message=$(git log -1 --pretty=%s)
fi
pnpm exec eas update \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — The update omits the EAS environment required for SDK 55 and later.

This app uses Expo SDK 57. EAS CLI 22 skips its missing-environment prompt when CI is set. It then loads no server variables. The update bundle or fingerprint can differ from the production build when an EAS variable affects configuration.

Add explicit environment values to the build profiles. Pass --environment "$UPDATE_BRANCH" here. Generate the reported fingerprint with the same environment.

echo '```'
} >> "$GITHUB_STEP_SUMMARY"

- name: Publish the update

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — The first OTA update resets the displayed app version to 0.0.1.

The EAS build workflow replaces the committed 0.0.1 before it builds TestFlight. This update workflow does not replace it. SettingsScreen reads Constants.expoConfig.version, which comes from the active update manifest. A 0.39.0 binary will therefore show Version 0.0.1 after this update.

Read the native application version for the About row, such as Application.nativeApplicationVersion. Show the update identifier separately if support work needs it.

echo
echo '```'
pnpm exec eas build:list --platform ios --limit 5 --non-interactive \
| grep -E 'Build number|Fingerprint|Version' || true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — This report can show unrelated builds and still allow a no-op release.

The command lists five iOS builds across all profiles and statuses. The following filter removes the profile and channel fields. It also hides query failure with || true. The job never compares the generated fingerprint with a finished production build. A native change can therefore produce a green update that reaches nobody.

Query a finished build for the selected channel or profile and fingerprint. Stop before publication when no compatible build exists. Use the selected channel for both this query and eas update --channel.

Comment thread apps/mobile/app.json
"policy": "fingerprint"
},
"updates": {
"url": "https://u.expo.dev/3dca8cca-f48a-4c3a-ba3d-3af40e58a588"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — Production clients do not require a project-controlled update signature.

The update configuration has a URL but no codeSigningCertificate or codeSigningMetadata. The publish command also supplies no private key. A compromise of the Expo update service, CDN, or update credential can therefore deliver arbitrary mobile code without an end-to-end signature check.

Add the public certificate and signing metadata to the app configuration. Keep the private key in the protected production environment. Pass --private-key-path during publication. This change requires a new binary.

@bb-slop-cop bb-slop-cop Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 SLOP COP 🚨 · review

Plain-English summary: This PR lets bb send JavaScript-only iOS fixes through Expo without a new TestFlight build. It also stops E2E builds from downloading production updates.

I found five issues that should be fixed before merge:

  • High: The production workflow can publish code from any selected Git ref. It has no protected deployment environment.
  • High: The SDK 57 update command omits the EAS environment. CI then bundles without the server variables used by the matching build.
  • High: The fingerprint report lists unrelated builds and never requires a compatible finished build. The workflow can succeed while no device receives the update.
  • Medium: The first update changes the displayed native version to the committed 0.0.1.
  • Medium: Clients do not require a project-controlled signature for update manifests.

I left a line comment for each issue. I also found two small performance concerns. The job installs all 73 workspace projects. A cold local install handled 2,323 packages. The existing iOS bundle is about 13 MB before assets. Expo Updates adds no JavaScript modules to that bundle.

The new workflow repeats setup code from .github/actions/setup-workspace. Reuse that action if its cache behavior fits this release job.

Validation passed for 834 mobile tests, type checks, lint, Expo config evaluation, fingerprint generation, native prebuild, and a cold iOS export. The E2E plist correctly sets EXUpdatesEnabled=false. Current GitHub checks pass. The label-gated iOS E2E job did not run.

The OTA client is native-only, so a browser server and doobie cannot test update delivery. I did not publish an update because that would change production state.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant