1name: Mobile EAS Production
2
3# Production builds and OTA updates run from CI (Linux) — never from a laptop.
4# Under the fingerprint runtime-version policy the fingerprint must be computed
5# in the same OS/pnpm as the EAS build; a macOS `eas build` computes a different
6# fingerprint (platform-specific deps + pnpm version) and errors. On this Linux
7# runner, with corepack pinning pnpm 10.24 in eas.json, local == build.
8#
9# Every merge to main that touches the mobile app reconciles, per platform:
10# 1. Store builds: when no production build matches the current native
11# fingerprint, or the latest build's version differs from app.config.ts,
12# cut a new build and submit it (TestFlight + Play internal track). Native
13# changes therefore ship a binary on merge, and bumping `version` is all
14# it takes to start the next release train — the first build of a version enters
15# external-TestFlight beta review immediately, and later builds of the
16# same version auto-approve until that version is released. After App
17# Store approval, Apple closes the release train and `version` must be
18# bumped before another iOS build can be submitted. Releasing to the App
19# Store stays a manual App Store Connect step.
20# 2. OTA: publish a production-channel update for each platform where at
21# least one finished production build matches the current native
22# fingerprint. Older binaries of the same major version with a matching
23# fingerprint receive it too; the major version is part of the
24# fingerprint (apps/mobile/fingerprint.config.js), so a new major never
25# reaches the previous major's store binaries over the air. Until a
26# build for the new fingerprint finishes, the OTA is skipped rather than
27# published into the void.
28# Hold native-change PRs (the "📱 Native Change" label) and merge them as a
29# batch to avoid a store build per PR.
30# workflow_dispatch remains as a manual override for both modes (e.g. to
31# retry an errored build or force an OTA).
32# Manual v2-preview builds keep the production app identity and disable OTA.
33# They auto-submit to TestFlight and the Google Play closed testing track v2.
34# Select the v2 branch when dispatching a preview build.
35on:
36 workflow_dispatch:
37 inputs:
38 mode:
39 description: "build (+ auto-submit to TestFlight / Google Play) or update (OTA)"
40 required: true
41 type: choice
42 default: build
43 options:
44 - build
45 - update
46 profile:
47 description: "Store build profile (v2-preview: TestFlight + Play closed track v2; build only)"
48 required: true
49 type: choice
50 default: production
51 options:
52 - production
53 - v2-preview
54 platform:
55 description: "Target platform"
56 required: true
57 type: choice
58 default: all
59 options:
60 - ios
61 - android
62 - all
63 version:
64 description: "Optional build version override (blank uses app.config.ts; an override is committed before building)"
65 required: false
66 type: string
67 message:
68 description: "OTA update message (mode=update only)"
69 required: false
70 type: string
71 push:
72 branches: [main]
73 paths:
74 - apps/mobile/**
75 - packages/client-runtime/**
76 - packages/contracts/**
77 - packages/shared/**
78 - assets/**
79 - scripts/**
80 - patches/**
81 - pnpm-lock.yaml
82 - pnpm-workspace.yaml
83 - .github/workflows/mobile-eas-production.yml
84
85# Serialize runs so OTAs publish in merge order. GitHub keeps at most one
86# queued run per group, so a burst of merges collapses into one run of the
87# newest commit — intermediate commits don't need their own OTA.
88concurrency:
89 group: mobile-eas-production
90 cancel-in-progress: false
91
92jobs:
93 production:
94 name: EAS ${{ inputs.profile || 'production' }} ${{ github.event_name == 'push' && 'auto' || inputs.mode }}
95 runs-on: blacksmith-8vcpu-ubuntu-2404
96 permissions:
97 contents: read
98 env:
99 APP_VARIANT: production
100 NODE_OPTIONS: --max-old-space-size=8192
101 steps:
102 - name: Reject preview OTA updates
103 if: github.event_name == 'workflow_dispatch' && inputs.profile == 'v2-preview' && inputs.mode == 'update'
104 run: |
105 echo "::error::V2 previews use store builds only. Select mode=build."
106 exit 1
107
108 - id: expo-token
109 name: Check for EXPO_TOKEN
110 env:
111 EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
112 run: |
113 if [ -n "$EXPO_TOKEN" ]; then
114 echo "present=true" >> "$GITHUB_OUTPUT"
115 else
116 echo "present=false" >> "$GITHUB_OUTPUT"
117 echo "EXPO_TOKEN is not available; skipping EAS production job."
118 fi
119
120 - id: version_app_token
121 name: Mint release app token for version override
122 if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.version != ''
123 uses: actions/create-github-app-token@v2
124 with:
125 app-id: ${{ secrets.RELEASE_APP_ID }}
126 private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
127 owner: ${{ github.repository_owner }}
128
129 - name: Checkout
130 if: steps.expo-token.outputs.present == 'true'
131 uses: actions/checkout@v6
132 with:
133 fetch-depth: 0
134 token: ${{ steps.version_app_token.outputs.token || github.token }}
135 # No sparse-checkout here: it makes actions/checkout fetch with
136 # --filter=blob:none, and eas-cli archives the project via
137 # `git clone --depth 1 file://<workspace>`, which fails (exit 128)
138 # when the partial clone can't serve the unfetched blobs.
139
140 - name: Setup Vite+
141 if: steps.expo-token.outputs.present == 'true'
142 uses: voidzero-dev/setup-vp@v1
143 with:
144 node-version-file: package.json
145 cache: true
146 run-install: |
147 args:
148 - --filter=@t3tools/mobile...
149
150 - name: Verify v2 preview source
151 if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.profile == 'v2-preview'
152 run: |
153 node --input-type=module -e '
154 import * as environment from "./packages/contracts/src/environment.ts";
155 if (environment.ORCHESTRATION_PROTOCOL_VERSION !== 2) {
156 throw new Error("V2 previews require a v2 source branch. Select the v2 branch when running this workflow.");
157 }
158 '
159
160 - name: Expose pnpm
161 if: steps.expo-token.outputs.present == 'true'
162 run: |
163 pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")"
164 vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin"
165 echo "$vp_pnpm_bin" >> "$GITHUB_PATH"
166 "$vp_pnpm_bin/pnpm" --version
167
168 - name: Setup EAS
169 if: steps.expo-token.outputs.present == 'true'
170 uses: expo/expo-github-action@v8
171 with:
172 eas-version: latest
173 token: ${{ secrets.EXPO_TOKEN }}
174 # npm, not pnpm: this only installs eas-cli into the action's own
175 # tool dir, and pnpm 11 hard-fails that install on dtrace-provider's
176 # ignored build script (no allowBuilds config outside the repo).
177 packager: npm
178
179 - name: Pull production environment variables
180 if: steps.expo-token.outputs.present == 'true'
181 working-directory: apps/mobile
182 env:
183 EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
184 run: eas env:pull production --non-interactive
185
186 - name: Apply manual version override
187 if: steps.version_app_token.outcome == 'success'
188 env:
189 GH_TOKEN: ${{ steps.version_app_token.outputs.token }}
190 APP_SLUG: ${{ steps.version_app_token.outputs.app-slug }}
191 RELEASE_VERSION: ${{ inputs.version }}
192 run: |
193 if [ "$GITHUB_REF_TYPE" != "branch" ]; then
194 echo "Version overrides require dispatching this workflow from a branch; received $GITHUB_REF_TYPE '$GITHUB_REF_NAME'." >&2
195 exit 1
196 fi
197 if ! [[ "$RELEASE_VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}$ ]]; then
198 echo "Version override must contain two or three dot-separated integers; received '$RELEASE_VERSION'." >&2
199 exit 1
200 fi
201
202 node --input-type=module -e '
203 import fs from "node:fs";
204 const path = "apps/mobile/app.config.ts";
205 const source = fs.readFileSync(path, "utf8");
206 const next = source.replace(
207 /^( version: ")[^"]+(".*)$/m,
208 `$1${process.env.RELEASE_VERSION}$2`,
209 );
210 if (next === source && !source.includes(` version: "${process.env.RELEASE_VERSION}"`)) {
211 throw new Error("Could not update app version");
212 }
213 fs.writeFileSync(path, next);
214 '
215 vp fmt apps/mobile/app.config.ts
216
217 if git diff --quiet -- apps/mobile/app.config.ts; then
218 echo "app.config.ts is already at $RELEASE_VERSION; no version commit needed."
219 exit 0
220 fi
221
222 user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)"
223 git config user.name "${APP_SLUG}[bot]"
224 git config user.email "${user_id}+${APP_SLUG}[bot]@users.noreply.github.com"
225 git add apps/mobile/app.config.ts
226 git commit \
227 -m "chore(mobile): bump app version to $RELEASE_VERSION" \
228 -m "Co-authored-by: codex <codex@users.noreply.github.com>"
229 git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}"
230
231 - name: Summarize manual build version
232 if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build'
233 working-directory: apps/mobile
234 env:
235 MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }}
236 run: |
237 version="$(npx expo config --json --type public | jq -r '.version')"
238 {
239 echo "## Manual $MOBILE_BUILD_PROFILE build"
240 echo
241 echo "- App version: \`$version\`"
242 echo "- Platform: \`${{ inputs.platform }}\`"
243 echo
244 echo "> Apple closes an iOS release train after App Store approval. Before building iOS, confirm \`$version\` is newer than the approved App Store version."
245 } >> "$GITHUB_STEP_SUMMARY"
246
247 - name: Build and submit (manual)
248 if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build'
249 working-directory: apps/mobile
250 env:
251 EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
252 MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }}
253 run: eas build --platform ${{ inputs.platform }} --profile "$MOBILE_BUILD_PROFILE" --auto-submit --non-interactive --no-wait
254
255 - name: Publish OTA update (manual)
256 if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'update'
257 working-directory: apps/mobile
258 env:
259 EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
260 run: |
261 eas update \
262 --channel production \
263 --environment production \
264 --platform ${{ inputs.platform }} \
265 --message "${{ inputs.message || format('Production OTA ({0})', github.sha) }}" \
266 --non-interactive
267
268 # One pass per platform. The fingerprint decides OTA vs store build:
269 # - a finished build matches → publish the OTA;
270 # - a queued/in-progress build matches → wait, the next merge after it
271 # finishes publishes the OTA (no duplicate build per merge);
272 # - no build matches → native drift, so cut and submit a new build.
273 # A version bump also cuts a build even when native code is unchanged,
274 # because only the major version is part of the fingerprint.
275 # An errored build for this fingerprint is not retried on push (it would
276 # rebuild on every merge); retry via workflow_dispatch mode=build.
277 - id: reconcile
278 name: Publish OTA or cut store build
279 if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push'
280 working-directory: apps/mobile
281 env:
282 EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
283 run: |
284 failed=0
285 version="$(npx expo config --json --type public | jq -r '.version')"
286 message="$(git log -1 --pretty=%s | head -c 120) ($(git rev-parse --short=9 HEAD))"
287 for platform in ios android; do
288 # eas-cli prints an environment-loaded notice to stdout before the
289 # JSON even with --json, so discard everything before the document.
290 hash="$(eas fingerprint:generate --platform "$platform" --environment production --json --non-interactive | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')"
291 matches="$(eas build:list --platform "$platform" --build-profile production --fingerprint-hash "$hash" --limit 20 --json --non-interactive)"
292 finished="$(jq '[.[] | select(.status == "FINISHED")] | length' <<<"$matches")"
293 active="$(jq '[.[] | select(.status == "NEW" or .status == "IN_QUEUE" or .status == "IN_PROGRESS")] | length' <<<"$matches")"
294 errored="$(jq '[.[] | select(.status == "ERRORED")] | length' <<<"$matches")"
295 latest_version="$(eas build:list --platform "$platform" --build-profile production --limit 1 --json --non-interactive | jq -r '.[0].appVersion // "none"')"
296
297 if [ "$finished" -gt 0 ]; then
298 if eas update \
299 --channel production \
300 --environment production \
301 --platform "$platform" \
302 --message "$message" \
303 --non-interactive; then
304 echo ":white_check_mark: $platform: OTA published to production (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY"
305 else
306 failed=1
307 echo ":x: $platform: OTA publish failed (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY"
308 fi
309 fi
310
311 reason=""
312 if [ "$finished" -eq 0 ] && [ "$active" -eq 0 ]; then
313 if [ "$errored" -gt 0 ]; then
314 echo ":x: $platform: the production build for fingerprint \`$hash\` errored — retry with workflow_dispatch mode=build" >> "$GITHUB_STEP_SUMMARY"
315 failed=1
316 continue
317 fi
318 reason="native fingerprint \`$hash\` has no build"
319 elif [ "$latest_version" != "$version" ] && [ "$active" -eq 0 ]; then
320 reason="latest production build is $latest_version, app.config.ts says $version"
321 fi
322
323 if [ -z "$reason" ]; then
324 if [ "$finished" -eq 0 ]; then
325 echo ":hourglass: $platform: production build for fingerprint \`$hash\` is in progress — OTA resumes on the next merge after it finishes" >> "$GITHUB_STEP_SUMMARY"
326 fi
327 continue
328 fi
329
330 echo "$platform: $reason — building"
331 if eas build --platform "$platform" --profile production --auto-submit --non-interactive --no-wait; then
332 echo ":building_construction: $platform: scheduled production build and submission for $version ($reason)" >> "$GITHUB_STEP_SUMMARY"
333 else
334 failed=1
335 echo ":x: $platform: production build or submission failed for $version" >> "$GITHUB_STEP_SUMMARY"
336 fi
337 done
338 exit "$failed"