Add EAS Update (OTA) to the mobile app - #2074
Conversation
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>
|
🚨 SLOP COP 🚨 · 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 |
There was a problem hiding this comment.
🚨 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 \ |
There was a problem hiding this comment.
🚨 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 |
There was a problem hiding this comment.
🚨 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 |
There was a problem hiding this comment.
🚨 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.
| "policy": "fingerprint" | ||
| }, | ||
| "updates": { | ||
| "url": "https://u.expo.dev/3dca8cca-f48a-4c3a-ba3d-3af40e58a588" |
There was a problem hiding this comment.
🚨 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.
There was a problem hiding this comment.
🚨 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.
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-updateswas not installed, and the build profiles ineas.jsoncarried no channels.What changed
expo-updates, withupdates.urlandruntimeVersion.policy: "fingerprint"inapp.json, plus a channel per build profile ineas.json.fingerprint.config.jsskipsExpoConfigVersions.mobile-ios-eas.ymlrewritesapp.jsonversionwith 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.jsturns the update client off whenBB_DISABLE_UPDATES=1, andmobile-e2e.ymlsets 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.ymlpublishes 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.How verified
Fingerprints, generated with
expo-updates fingerprint:generate --platform ios:version: 0.0.1, no skip7f9c90b3…version: 0.39.0, no skip0f650d4d…version: 0.0.1, with skipb162a732…version: 0.39.0, with skipb162a732…f9b4c845…So the marketing version no longer forks the runtime version, and a native change still does.
expo prebuild --platform ios, reading the generatedExpo.plist:EXUpdatesEnabledisfalsewithBB_DISABLE_UPDATES=1andtruewithout it.pnpm exec turbo run typecheck lint test --filter=@bb/mobile --force: green, 834 tests. The newapp.config.test.tscovers both silent failure modes; its E2E case fails when the guard inapp.config.jsis 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.