Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/mobile-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ jobs:
- name: Compute native cache key
id: native-key
run: |
echo "key=${{ runner.os }}-mobile-native-${{ hashFiles('pnpm-lock.yaml', 'apps/mobile/app.json', 'apps/mobile/package.json', 'patches/**') }}" >> "$GITHUB_OUTPUT"
echo "key=${{ runner.os }}-mobile-native-${{ hashFiles('pnpm-lock.yaml', 'apps/mobile/app.json', 'apps/mobile/app.config.js', 'apps/mobile/package.json', 'patches/**') }}" >> "$GITHUB_OUTPUT"
- name: Restore CocoaPods
id: pods-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
Expand All @@ -178,11 +178,17 @@ jobs:
path: ~/Library/Developer/Xcode/DerivedData/bb-*/Build
key: ${{ steps.native-key.outputs.key }}-deriveddata

# BB_DISABLE_UPDATES: apps/mobile/app.config.js then sets
# `updates.enabled: false`, so prebuild writes EXUpdatesEnabled=false
# into Expo.plist. Without it a Release binary asks the production
# channel for a new bundle at launch and can replace the embedded E2E
# bundle in the middle of a flow.
- name: Prebuild and install pods
working-directory: apps/mobile
env:
LANG: en_US.UTF-8
CI: "1"
BB_DISABLE_UPDATES: "1"
run: |
set -x
npx expo prebuild --platform ios --no-install
Expand Down Expand Up @@ -210,6 +216,8 @@ jobs:
NODE_OPTIONS: --max-old-space-size=8192
EXPO_PUBLIC_BB_E2E: "1"
EXPO_PUBLIC_BB_SERVER_URL: http://127.0.0.1:${{ env.BB_MOBILE_E2E_PORT }}
# `expo run:ios` evaluates the app config again; keep it in step.
BB_DISABLE_UPDATES: "1"
run: |
set -x
rm -rf build-output
Expand Down
126 changes: 126 additions & 0 deletions .github/workflows/mobile-update.yml
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

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.

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

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.

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.

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 \

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.

--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"
44 changes: 42 additions & 2 deletions apps/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -799,8 +799,48 @@ push key); nobody needs a local Xcode signing setup to ship.
check against `https://<handle>.getbb.app/threads/…`. Android signing
(`eas credentials -p android`, FCM V1, `ASSETLINKS_SHA256_FINGERPRINTS`)
is still open.
- `eas update` (JS-only fixes over the air) is deferred: `expo-updates` is
not installed, so the profiles define no update channels.

## Over-the-air updates (EAS Update)

`expo-updates` ships in the app, so a JS-only fix can reach installed builds
without a new binary. Each build profile in `eas.json` carries a channel of its
own name (`production`, `preview`, `development`, `development-device`), and a
channel points at the update branch of the same name.

- **Publish**: Actions tab → "Mobile update (EAS Update)" → Run workflow, or
`gh workflow run mobile-update.yml -f branch=production`. It is manual on
purpose: an update reaches every tester in minutes with no review in
between. Locally the same publish is
`pnpm exec eas update --branch production --platform ios --message "…"`.
- **What an update can carry**: anything under `src/` and `app/` — screens,
logic, styles, copy, assets. It cannot carry a new native module, an Expo SDK
bump, or a change to the native parts of `app.json` (permission strings,
`UIBackgroundModes`, associated domains, scheme, icon). Those need
`mobile-ios-eas.yml`.
- **Which builds an update reaches**: `app.json` sets
`runtimeVersion.policy: "fingerprint"`. The fingerprint hashes the native
inputs, and an update installs only on a binary with the same hash, so a
native change cannot land on an incompatible build — it reaches nobody
instead. The publish workflow prints the fingerprint next to the recent
builds' fingerprints, because "reached nobody" otherwise looks like success.
`eas.json` is a fingerprint input too, so editing it also forks the
fingerprint and the next update needs a fresh build to land on.
- **`fingerprint.config.js`** sets `sourceSkips: ["ExpoConfigVersions"]`. The
EAS build job rewrites `app.json` `version` with the npm version on every
nightly. Without this skip the version alone would fork the fingerprint each
night and no update would ever match a build. Verified by generating the
fingerprint at `0.0.1` and at `0.39.0`: identical with the skip, different
without it. A native change still forks it.
- **Delivery**: the client checks on launch and downloads in the background,
then applies the update on the next cold start. A tester who never quits the
app stays on the old bundle.
- **E2E builds carry no update client.** `mobile-e2e.yml` sets
`BB_DISABLE_UPDATES=1`, and `app.config.js` turns
`updates.enabled` off, so prebuild writes `EXUpdatesEnabled=false` into
`Expo.plist`. A Release binary with updates on would fetch the production
bundle at launch and swap the embedded E2E bundle mid-flow.
- **Billing**: EAS Update bills on monthly active users. Check the `bb-team`
plan before opening the app to a large external group.

## Local state

Expand Down
22 changes: 22 additions & 0 deletions apps/mobile/app.config.js
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 },
};
};
54 changes: 54 additions & 0 deletions apps/mobile/app.config.test.ts
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");
});
});
6 changes: 6 additions & 0 deletions apps/mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"

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.

}
}
}
12 changes: 8 additions & 4 deletions apps/mobile/eas.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,23 @@
"distribution": "internal",
"ios": {
"simulator": true
}
},
"channel": "development"
},
"development-device": {
"extends": "development",
"ios": {
"simulator": false
}
},
"channel": "development-device"
},
"preview": {
"distribution": "internal"
"distribution": "internal",
"channel": "preview"
},
"production": {
"autoIncrement": true
"autoIncrement": true,
"channel": "production"
}
},
"submit": {
Expand Down
18 changes: 18 additions & 0 deletions apps/mobile/fingerprint.config.js
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"],
};
3 changes: 2 additions & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"android": "expo run:android",
"prebuild": "expo prebuild",
"typecheck": "tsc --noEmit",
"lint": "eslint app src scripts metro.config.js --ext .ts,.tsx",
"lint": "eslint app src scripts metro.config.js app.config.js fingerprint.config.js --ext .ts,.tsx",
"test": "vitest run --config vitest.config.ts",
"theme:generate": "node --conditions=source --import tsx scripts/generate-native-theme.ts",
"terminal:build": "node --conditions=source --import tsx scripts/build-terminal-page.ts",
Expand Down Expand Up @@ -59,6 +59,7 @@
"expo-splash-screen": "~57.0.7",
"expo-status-bar": "~57.0.1",
"expo-system-ui": "~57.0.2",
"expo-updates": "~57.0.16",
"expo-web-browser": "~57.0.2",
"mdast-util-to-string": "^4.0.0",
"nativewind": "5.0.0-preview.4",
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default defineWorkspaceTestConfig({
test: {
silent: "passed-only",
environment: "node",
include: ["src/**/*.test.ts"],
include: ["src/**/*.test.ts", "app.config.test.ts"],
passWithNoTests: true,
testTimeout: 15_000,
},
Expand Down
Loading
Loading