From 8555e8abd900ee11146e45a4858a4454a32bcf71 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 17 Oct 2026 11:02:23 +0700 Subject: [PATCH] fix(release): distribute stable iOS builds to TestFlight testers (#7189) Replace the upload-only submission with the existing TestFlight distribution job shape so external testers receive stable releases. Keep App Store review targeting the same version and build without a second binary upload. --- docs/android.md | 6 ++-- docs/release.md | 23 +++++++------- .../app/.eas/workflows/release-mobile.yml | 11 ++++--- scripts/ci-workflow.test.mjs | 30 ++++++++++++++++++- 4 files changed, 52 insertions(+), 18 deletions(-) diff --git a/docs/android.md b/docs/android.md index 75908a8ae2..c90a27efec 100644 --- a/docs/android.md +++ b/docs/android.md @@ -205,12 +205,12 @@ adb exec-out screencap -p > screenshot.png Stable tag pushes like `v0.1.0` trigger: -- The EAS GitHub app on Expo servers (iOS + Android production builds + store submit). There is no workflow file in this repo for it. +- The EAS GitHub app on Expo servers, using `packages/app/.eas/workflows/release-mobile.yml` (iOS + Android production builds and distribution). - `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release). -iOS auto-submits to App Store review via a Fastlane lane after EAS uploads to TestFlight. Android auto-submits to the Play Store via EAS-managed credentials. +iOS distributes the same build to external TestFlight testers and App Store review; see [Mobile builds (EAS)](release.md#mobile-builds-eas) for the review paths. Android auto-submits to the Play Store via EAS-managed credentials. -Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores. +Beta tags like `v0.1.1-beta.1` trigger the GitHub APK workflow and EAS `Release iOS Beta`. They publish a GitHub prerelease APK and distribute iOS through TestFlight without submitting to production stores. `android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. The GitHub APK workflow supports `workflow_dispatch` with an existing `tag` input so you can rebuild without cutting a new tag. diff --git a/docs/release.md b/docs/release.md index cdac451f23..2be4b008db 100644 --- a/docs/release.md +++ b/docs/release.md @@ -305,12 +305,14 @@ macOS 13 maps to Darwin 22. The two values use different version domains; do not iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed: - **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials, no Fastlane). -- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads to TestFlight, and a Fastlane lane submits the build for App Store review. +- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads once through a `testflight` job, distributes the build to the `Paseo Beta` external group with Beta App Review submission, and a Fastlane lane submits that same build for App Store review. - **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo. EAS uses the local app version source. `packages/app/app.config.js` derives the native version from the package version. Android `versionCode` is `major * 1_000_000 + minor * 1_000 + patch`. iOS reserves 1,000 build slots per app version: beta `N` uses slot `N`, and stable uses slot `999`. For example, `0.2.6-beta.2` appears in App Store Connect as version `0.2.6` build `2006002`; stable uses build `2006999`. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut the next beta or patch instead of relying on EAS remote auto-increment. -Beta tags run `Release iOS Beta`. The workflow uploads the build to TestFlight, distributes it to the persistent `Paseo Beta` external group, and submits it for Beta App Review. Testers and the group are managed once in App Store Connect; releases require no dashboard action. +Stable tags run `Release Mobile`; beta tags run `Release iOS Beta`. Both distribute to the persistent `Paseo Beta` external group and submit for Beta App Review, so testers receive stable releases as well as betas. Testers, the group, and required TestFlight test information are managed once in App Store Connect. + +Stable iOS releases use one binary for two Apple review paths: Beta App Review for external TestFlight distribution and App Store review. [Apple says](https://developer.apple.com/help/app-store-connect/test-a-beta-version/invite-external-testers) the first submitted build requires a full TestFlight review; later builds for the same version might not. Do not assume a stable patch skips review. There is no mobile-release workflow under `.github/workflows`. The EAS GitHub app reads the workflows under `packages/app/.eas/workflows` and handles tag triggering directly. @@ -334,7 +336,7 @@ npx eas build:list --platform android --limit 5 --non-interactive --json # Inspect a specific build. npx eas build:view -# Inspect the full release workflow, including submit_ios, submit_android, +# Inspect the full release workflow, including distribute_ios, submit_android, # and submit_ios_for_review. npx eas workflow:view --json @@ -347,17 +349,17 @@ npx eas build:view --json | jq '.logFiles[]' A build's `gitCommitHash` must match the release tag commit. `status` walks through `NEW` → `IN_QUEUE` → `IN_PROGRESS` → `FINISHED` (or `ERRORED`/`CANCELED`). The EAS workflow run's `gitCommitHash` and `trigger` must also match the release tag. -Once a build is `FINISHED`, EAS still has release-critical work to do: Android must submit to the Play Store, and iOS must upload to TestFlight **and** submit the build for App Store review. The release is not done until all platforms are on their way through the stores. +Once a build is `FINISHED`, EAS still has release-critical work to do: Android must submit to the Play Store, and iOS must distribute to the external TestFlight group with Beta App Review submission **and** submit the same build for App Store review. The release is not done until all platforms are on their way through the stores. For the `Release Mobile` EAS workflow, these jobs must pass: - `build_ios` — iOS binary built -- `submit_ios` — iOS binary uploaded to App Store Connect/TestFlight +- `distribute_ios` — iOS binary uploaded once, added to `Paseo Beta`, and submitted for Beta App Review - `submit_ios_for_review` — iOS build submitted for App Store review via Fastlane - `build_android` — Android store binary built - `submit_android` — Android binary submitted to the Play Store -Do not treat `build_ios: SUCCESS` or `submit_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight. +Do not treat `build_ios: SUCCESS` or `distribute_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight. To confirm the submission landed, inspect the EAS workflow with `npx eas workflow:view --json`. App Store Connect (review state for the matching version/build) and the Play Console track are the final ground truth. @@ -380,8 +382,9 @@ Windows, and Android APK assets are present along with the channel manifests (`latest-mac.yml`, `latest-linux.yml`, and `latest.yml` for stable; `beta-mac.yml`, `beta-linux.yml`, and `beta.yml` for beta). -For stable releases, also confirm every required mobile build, upload, store -submission, and review-submission job for the release commit. For betas, confirm +For stable releases, also confirm every required mobile build, external +TestFlight distribution, Beta App Review submission, and store submission job +for the release commit. For betas, confirm the beta EAS workflow completed its TestFlight distribution and Beta App Review path. Delete the heartbeat only after every applicable checklist item passes, then report the release as shipped. @@ -396,7 +399,7 @@ Pattern: "timezone": "UTC", "maxRuns": 120, "expiresIn": "24h", - "prompt": "Resume the vX.Y.Z release babysit for commit . Check npm tags; every GitHub Actions run for the release branch and tag; the published GitHub Release body, expected desktop/APK assets, and channel manifests; the Docker image; and the matching EAS workflow. Completion requires every applicable checklist item. For stable, require build_ios, submit_ios, submit_ios_for_review, build_android, and submit_android to succeed. For beta, require the beta TestFlight distribution and Beta App Review path. If work is pending, wait for the next heartbeat. If a failure can be retried safely for the same version, follow the failed-release procedure; otherwise report the blocker. When every applicable completion-checklist item passes, delete THIS heartbeat, report shipped, and stop.", + "prompt": "Resume the vX.Y.Z release babysit for commit . Check npm tags; every GitHub Actions run for the release branch and tag; the published GitHub Release body, expected desktop/APK assets, and channel manifests; the Docker image; and the matching EAS workflow. Completion requires every applicable checklist item. For stable, require build_ios, distribute_ios (Paseo Beta external distribution and Beta App Review submission), submit_ios_for_review, build_android, and submit_android to succeed. For beta, require the beta TestFlight distribution and Beta App Review path. If work is pending, wait for the next heartbeat. If a failure can be retried safely for the same version, follow the failed-release procedure; otherwise report the blocker. When every applicable completion-checklist item passes, delete THIS heartbeat, report shipped, and stop.", } ``` @@ -680,7 +683,7 @@ Every entry covers `previous stable release → release source`: first beta, lat - [ ] GitHub `Release Notes Sync` is green and the release body matches the stable changelog entry - [ ] EAS `Release Mobile` workflow for the same tag is green - [ ] EAS iOS `build_ios` completes for the same tag -- [ ] EAS iOS `submit_ios` succeeds, uploading the build to App Store Connect/TestFlight +- [ ] EAS iOS `distribute_ios` succeeds, uploading the binary once, distributing it to `Paseo Beta`, and submitting it for Beta App Review - [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review - [ ] EAS Android `build_android` completes for the same tag - [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track diff --git a/packages/app/.eas/workflows/release-mobile.yml b/packages/app/.eas/workflows/release-mobile.yml index 13968b8b55..26790ef22f 100644 --- a/packages/app/.eas/workflows/release-mobile.yml +++ b/packages/app/.eas/workflows/release-mobile.yml @@ -23,13 +23,16 @@ jobs: platform: android profile: production - submit_ios: - name: Submit iOS + distribute_ios: + name: Distribute iOS to TestFlight needs: [build_ios] - type: submit + type: testflight params: build_id: ${{ needs.build_ios.outputs.build_id }} profile: production + external_groups: + - Paseo Beta + submit_beta_review: true submit_android: name: Submit Android @@ -41,7 +44,7 @@ jobs: submit_ios_for_review: name: Submit iOS for App Store review - needs: [build_ios, submit_ios] + needs: [build_ios, distribute_ios] environment: production runs_on: macos-medium steps: diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index 8ea5411026..d71fa6e184 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -37,7 +37,7 @@ function jobBlocks(source) { let currentJob; for (const line of source.split("\n")) { - const jobMatch = /^ ([a-z0-9-]+):\s*$/.exec(line); + const jobMatch = /^ ([a-z0-9_-]+):\s*$/.exec(line); if (jobMatch) { currentJob = jobMatch[1]; jobs.set(currentJob, []); @@ -303,3 +303,31 @@ test("desktop packaging smokes main pushes and only the pull requests that touch assert.match(source, new RegExp(`${action}@[0-9a-f]{40} # v\\d+\\.\\d+\\.\\d+`)); } }); + +test("stable iOS releases upload once and distribute to external testers before App Store review", () => { + const source = readFileSync( + new URL("packages/app/.eas/workflows/release-mobile.yml", repoRoot), + "utf8", + ); + const jobs = jobBlocks(source); + const uploads = [...jobs.entries()].filter( + ([, lines]) => + lines.some((line) => /^ type: (submit|testflight)$/.test(line)) && + lines.some((line) => line.includes("needs.build_ios.outputs.build_id")), + ); + assert.equal(uploads.length, 1, "the iOS binary must be uploaded exactly once"); + const [distributionId, distributionLines] = uploads[0]; + const distribution = distributionLines.join("\n"); + assert.match(distribution, /^ type: testflight$/m); + assert.match(distribution, /external_groups:\s*\n\s+- Paseo Beta/); + assert.match(distribution, /^ submit_beta_review: true$/m); + + const review = jobs.get("submit_ios_for_review").join("\n"); + assert.match(review, new RegExp(`needs: \\[build_ios, ${distributionId}\\]`)); + assert.match(review, /APP_VERSION="\$\{\{ needs\.build_ios\.outputs\.app_version \}\}"/); + assert.match( + review, + /APP_BUILD_VERSION="\$\{\{ needs\.build_ios\.outputs\.app_build_version \}\}"/, + ); + assert.match(review, /bundle exec fastlane ios submit_review/); +});