| ... | ... | @@ -7,10 +7,11 @@ name: Mobile EAS Production |
| 7 | 7 | # runner, with corepack pinning pnpm 10.24 in eas.json, local == build. |
| 8 | 8 | # |
| 9 | 9 | # 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 |
| 14 | 15 | # external-TestFlight beta review immediately, and later builds of the |
| 15 | 16 | # same version auto-approve until that version is released. After App |
| 16 | 17 | # Store approval, Apple closes the release train and `version` must be |
| ... | ... | @@ -21,10 +22,11 @@ name: Mobile EAS Production |
| 21 | 22 | # fingerprint. Older binaries of the same major version with a matching |
| 22 | 23 | # fingerprint receive it too; the major version is part of the |
| 23 | 24 | # 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. |
| 28 | 30 | # workflow_dispatch remains as a manual override for both modes (e.g. to |
| 29 | 31 | # retry an errored build or force an OTA). |
| 30 | 32 | # Manual v2-preview builds keep the production app identity and disable OTA. |
| ... | ... | @@ -263,61 +265,74 @@ jobs: |
| 263 | 265 | --message "${{ inputs.message || format('Production OTA ({0})', github.sha) }}" \ |
| 264 | 266 | --non-interactive |
| 265 | 267 | |
| 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 |
| 272 | 279 | if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push' |
| 273 | | continue-on-error: true |
| 274 | 280 | working-directory: apps/mobile |
| 275 | 281 | env: |
| 276 | 282 | EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} |
| 277 | 283 | run: | |
| 278 | 284 | failed=0 |
| 279 | 285 | 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: | |
| 302 | 286 | message="$(git log -1 --pretty=%s | head -c 120) ($(git rev-parse --short=9 HEAD))" |
| 303 | 287 | for platform in ios android; do |
| 304 | 288 | # eas-cli prints an environment-loaded notice to stdout before the |
| 305 | 289 | # JSON even with --json, so discard everything before the document. |
| 306 | 290 | 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 \ |
| 310 | 299 | --channel production \ |
| 311 | 300 | --environment production \ |
| 312 | 301 | --platform "$platform" \ |
| 313 | 302 | --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" |
| 316 | 333 | 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" |
| 318 | 336 | fi |
| 319 | 337 | done |
| 320 | | |
| 321 | | - name: Propagate store build failure |
| 322 | | if: steps.store_builds.outcome == 'failure' |
| 323 | | run: exit 1 |
| 338 | exit "$failed" |