-
Notifications
You must be signed in to change notification settings - Fork 289
Add EAS Update (OTA) to the mobile app #2074
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| # Publish a JS-only over-the-air update for the bb mobile app (EAS Update). | ||
| # | ||
| # An update carries the JS bundle and its assets. It cannot carry native | ||
| # changes: a new native module, an Expo SDK bump, or an edit to app.json's | ||
| # native config all need a full build through mobile-ios-eas.yml. The | ||
| # `fingerprint` runtimeVersion policy in apps/mobile/app.json enforces this — | ||
| # it hashes the native inputs, and an update installs only on a binary whose | ||
| # hash matches, so a native change simply reaches no installed build. | ||
| # | ||
| # Manual only. An update reaches every tester on the channel within minutes | ||
| # and there is no review between this job and their phones, so a bad update | ||
| # is worse than a bad nightly. Publish deliberately. | ||
| name: Mobile update (EAS Update) | ||
|
|
||
| on: | ||
| workflow_dispatch: | ||
| inputs: | ||
| branch: | ||
| description: EAS Update branch. The `production` channel that TestFlight builds carry points at the branch of the same name. | ||
| required: true | ||
| type: choice | ||
| default: production | ||
| options: | ||
| - production | ||
| - preview | ||
| message: | ||
| description: Update message shown on expo.dev. Empty uses the commit subject. | ||
| required: false | ||
| type: string | ||
| default: "" | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| update: | ||
| name: Publish an update to ${{ inputs.branch }} | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 30 | ||
| # Two concurrent publishes to one branch would race on which update ends | ||
| # up newest; the loser silently never ships. | ||
| concurrency: | ||
| group: mobile-update-${{ inputs.branch }} | ||
| cancel-in-progress: false | ||
|
|
||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | ||
|
|
||
| - name: Set up pnpm | ||
| uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 | ||
| with: | ||
| version: 9.15.0 | ||
| run_install: false | ||
|
|
||
| - name: Set up Node.js | ||
| uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 | ||
| with: | ||
| node-version: 22.x | ||
| cache: pnpm | ||
|
|
||
| - name: Install dependencies | ||
| run: pnpm install --frozen-lockfile --prefer-offline | ||
|
|
||
| - name: Require the EAS token | ||
| env: | ||
| EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} | ||
| run: | | ||
| set -euo pipefail | ||
| if [[ -z "${EXPO_TOKEN:-}" ]]; then | ||
| echo "::error::EAS Update needs the EXPO_TOKEN secret." | ||
| exit 1 | ||
| fi | ||
|
|
||
| # The fingerprint decides which builds this update can reach, so print it | ||
| # next to the fingerprints of the recent builds. A mismatch means the | ||
| # update reaches nobody, and that is invisible in the publish output. | ||
| - name: Report the runtime fingerprint | ||
| working-directory: apps/mobile | ||
| env: | ||
| EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} | ||
| run: | | ||
| set -euo pipefail | ||
| fingerprint=$(pnpm exec expo-updates fingerprint:generate --platform ios | node -e ' | ||
| let raw = ""; | ||
| process.stdin.on("data", (chunk) => (raw += chunk)); | ||
| process.stdin.on("end", () => { | ||
| console.log(JSON.parse(raw.slice(raw.indexOf("{"))).hash); | ||
| }); | ||
| ') | ||
| { | ||
| echo "## Runtime fingerprint" | ||
| echo | ||
| echo "This update installs only on iOS builds with fingerprint \`${fingerprint}\`." | ||
| echo | ||
| echo "Recent builds:" | ||
| 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. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚨 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 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 |
||
| echo '```' | ||
| } >> "$GITHUB_STEP_SUMMARY" | ||
|
|
||
| - name: Publish the update | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚨 The EAS build workflow replaces the committed Read the native application version for the About row, such as |
||
| working-directory: apps/mobile | ||
| env: | ||
| EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} | ||
| UPDATE_BRANCH: ${{ inputs.branch }} | ||
| UPDATE_MESSAGE: ${{ inputs.message }} | ||
| run: | | ||
| set -euo pipefail | ||
| message="${UPDATE_MESSAGE:-}" | ||
| if [[ -z "$message" ]]; then | ||
| message=$(git log -1 --pretty=%s) | ||
| fi | ||
| pnpm exec eas update \ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚨 This app uses Expo SDK 57. EAS CLI 22 skips its missing-environment prompt when Add explicit environment values to the build profiles. Pass |
||
| --branch "$UPDATE_BRANCH" \ | ||
| --platform ios \ | ||
| --message "$message" \ | ||
| --non-interactive | tee eas-update.log | ||
| { | ||
| echo | ||
| echo "## EAS Update" | ||
| echo | ||
| grep -Eo 'https://expo\.dev/[^ ]+' eas-update.log | sed 's/^/- /' || true | ||
| } >> "$GITHUB_STEP_SUMMARY" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| /** | ||
| * Expo reads app.json first and passes it here as `config`, so app.json stays | ||
| * the single description of the app and this file only applies build-time | ||
| * overrides. | ||
| * | ||
| * `BB_DISABLE_UPDATES=1` turns the expo-updates client off in the built | ||
| * binary. The Mobile E2E workflow builds the app in Release, and a Release | ||
| * binary with updates enabled asks the production channel for a new bundle at | ||
| * launch. That bundle would replace the embedded E2E bundle in the middle of a | ||
| * Maestro flow, and the failures would look random. The E2E build is never | ||
| * distributed, so it needs no update client. | ||
| */ | ||
| module.exports = ({ config }) => { | ||
| if (process.env.BB_DISABLE_UPDATES !== "1") { | ||
| return config; | ||
| } | ||
|
|
||
| return { | ||
| ...config, | ||
| updates: { ...config.updates, enabled: false }, | ||
| }; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import appConfigFactory from "./app.config.js"; | ||
| import appJson from "./app.json"; | ||
| import fingerprintConfig from "./fingerprint.config.js"; | ||
|
|
||
| // The two config files below decide who receives an over-the-air update. | ||
| // Both failure modes are silent: an update that reaches nobody looks like a | ||
| // successful publish, and an E2E build that fetches a production bundle looks | ||
| // like a flaky flow. Neither shows up in a typecheck. | ||
|
|
||
| const evaluate = () => appConfigFactory({ config: appJson.expo }); | ||
|
|
||
| describe("app.config.js", () => { | ||
| it("keeps the update client on by default", () => { | ||
| delete process.env.BB_DISABLE_UPDATES; | ||
| const config = evaluate(); | ||
|
|
||
| expect(config.updates).toEqual({ url: appJson.expo.updates.url }); | ||
| expect(config).toMatchObject({ runtimeVersion: { policy: "fingerprint" } }); | ||
| }); | ||
|
|
||
| it("disables updates for the E2E Release build", () => { | ||
| process.env.BB_DISABLE_UPDATES = "1"; | ||
| try { | ||
| // Release E2E binaries must not ask the production channel for a bundle | ||
| // mid-flow; prebuild turns this into EXUpdatesEnabled=false. | ||
| expect(evaluate().updates).toMatchObject({ enabled: false }); | ||
| } finally { | ||
| delete process.env.BB_DISABLE_UPDATES; | ||
| } | ||
| }); | ||
|
|
||
| it("changes nothing else about the app config", () => { | ||
| process.env.BB_DISABLE_UPDATES = "1"; | ||
| try { | ||
| const { updates: _disabled, ...rest } = evaluate(); | ||
| const { updates: _original, ...original } = appJson.expo; | ||
|
|
||
| expect(rest).toEqual(original); | ||
| } finally { | ||
| delete process.env.BB_DISABLE_UPDATES; | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| describe("fingerprint.config.js", () => { | ||
| // mobile-ios-eas.yml rewrites app.json `version` on every nightly. Without | ||
| // this skip the version alone forks the runtime fingerprint each night, and | ||
| // no update ever matches an installed build. | ||
| it("keeps the marketing version out of the fingerprint", () => { | ||
| expect(fingerprintConfig.sourceSkips).toContain("ExpoConfigVersions"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -96,6 +96,12 @@ | |
| "eas": { | ||
| "projectId": "3dca8cca-f48a-4c3a-ba3d-3af40e58a588" | ||
| } | ||
| }, | ||
| "runtimeVersion": { | ||
| "policy": "fingerprint" | ||
| }, | ||
| "updates": { | ||
| "url": "https://u.expo.dev/3dca8cca-f48a-4c3a-ba3d-3af40e58a588" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚨 The update configuration has a URL but no Add the public certificate and signing metadata to the app configuration. Keep the private key in the protected production environment. Pass |
||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| /** | ||
| * Fingerprint inputs for the `fingerprint` runtimeVersion policy (app.json). | ||
| * | ||
| * The fingerprint decides which binaries an `eas update` can reach: an update | ||
| * is published for one runtime version and installs only on builds with the | ||
| * same one. By default the fingerprint hashes the whole evaluated Expo config, | ||
| * including `version`. That is wrong here, because | ||
| * .github/workflows/mobile-ios-eas.yml rewrites `version` on every nightly | ||
| * with the npm version. Each nightly would then fork the runtime version, and | ||
| * an update would reach only the one build made from that exact version. | ||
| * | ||
| * `ExpoConfigVersions` drops `version`, `ios.buildNumber` and | ||
| * `android.versionCode` from the hash. Those fields change no native code, so | ||
| * a build differing only by version stays update-compatible. | ||
| */ | ||
| module.exports = { | ||
| sourceSkips: ["ExpoConfigVersions"], | ||
| }; |
There was a problem hiding this comment.
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_TOKENand 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/mainfor 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.