@@ -7,10 +7,11 @@ name: Mobile EAS Production
77# runner, with corepack pinning pnpm 10.24 in eas.json, local == build.
88#
99# Every merge to main that touches the mobile app reconciles, per platform:
10- # 1. Store builds: if the latest production build's version differs from
11- # app.config.ts, cut a new build and submit it (TestFlight + Play internal
12- # track). Bumping `version` is therefore all it takes to
13- # start the next release train — the first build of a version enters
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
1415# external-TestFlight beta review immediately, and later builds of the
1516# same version auto-approve until that version is released. After App
1617# Store approval, Apple closes the release train and `version` must be
@@ -21,10 +22,11 @@ name: Mobile EAS Production
2122# fingerprint. Older binaries of the same major version with a matching
2223# fingerprint receive it too; the major version is part of the
2324# fingerprint (apps/mobile/fingerprint.config.js), so a new major never
24- # reaches the previous major's store binaries over the air. When native
25- # drift means no binary could install the update,
26- # it is skipped and flagged in the job summary instead of published
27- # into the void.
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.
2830# workflow_dispatch remains as a manual override for both modes (e.g. to
2931# retry an errored build or force an OTA).
3032# Manual v2-preview builds keep the production app identity and disable OTA.
@@ -263,61 +265,74 @@ jobs:
263265 --message "${{ inputs.message || format('Production OTA ({0})', github.sha) }}" \
264266 --non-interactive
265267
266- # No --status filter on build:list: an in-queue/in-progress build must
267- # count as existing, or every merge during the build window would cut a
268- # duplicate. After an errored build, retry via workflow_dispatch
269- # mode=build — pushes won't re-trigger it until the app version changes.
270- - id : store_builds
271- name : Ensure store builds exist for the current app version
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
272279 if : steps.expo-token.outputs.present == 'true' && github.event_name == 'push'
273- continue-on-error : true
274280 working-directory : apps/mobile
275281 env :
276282 EXPO_TOKEN : ${{ secrets.EXPO_TOKEN }}
277283 run : |
278284 failed=0
279285 version="$(npx expo config --json --type public | jq -r '.version')"
280- for platform in ios android; do
281- latest="$(eas build:list --platform "$platform" --build-profile production --limit 1 --json --non-interactive | jq -r '.[0].appVersion // "none"')"
282- if [ "$latest" = "$version" ]; then
283- echo "$platform: production build for $version already exists (or is in progress)"
284- continue
285- fi
286- echo "$platform: latest production build is $latest, app.config.ts says $version — building"
287- if eas build --platform "$platform" --profile production --auto-submit --non-interactive --no-wait; then
288- echo ":building_construction: $platform: scheduled production build and submission for $version" >> "$GITHUB_STEP_SUMMARY"
289- else
290- failed=1
291- echo ":x: $platform: production build or submission failed for $version" >> "$GITHUB_STEP_SUMMARY"
292- fi
293- done
294- exit "$failed"
295-
296- - name : Publish fingerprint-gated OTA
297- if : steps.expo-token.outputs.present == 'true' && github.event_name == 'push'
298- working-directory : apps/mobile
299- env :
300- EXPO_TOKEN : ${{ secrets.EXPO_TOKEN }}
301- run : |
302286 message="$(git log -1 --pretty=%s | head -c 120) ($(git rev-parse --short=9 HEAD))"
303287 for platform in ios android; do
304288 # eas-cli prints an environment-loaded notice to stdout before the
305289 # JSON even with --json, so discard everything before the document.
306290 hash="$(eas fingerprint:generate --platform "$platform" --environment production --json --non-interactive | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')"
307- matching="$(eas build:list --platform "$platform" --build-profile production --status finished --fingerprint-hash "$hash" --limit 1 --json --non-interactive | jq 'length')"
308- if [ "$matching" -gt 0 ]; then
309- eas update \
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 \
310299 --channel production \
311300 --environment production \
312301 --platform "$platform" \
313302 --message "$message" \
314- --non-interactive
315- echo ":white_check_mark: $platform: OTA published to production (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY"
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"
316333 else
317- echo ":warning: $platform: no finished production build matches fingerprint \`$hash\` — OTA skipped; JS changes reach $platform only once a matching build ships" >> "$GITHUB_STEP_SUMMARY"
334+ failed=1
335+ echo ":x: $platform: production build or submission failed for $version" >> "$GITHUB_STEP_SUMMARY"
318336 fi
319337 done
320-
321- - name : Propagate store build failure
322- if : steps.store_builds.outcome == 'failure'
323- run : exit 1
338+ exit "$failed"
0 commit comments