| ... | ... | @@ -8,12 +8,14 @@ name: Mobile EAS Production |
| 8 | 8 | # |
| 9 | 9 | # Every merge to main that touches the mobile app reconciles, per platform: |
| 10 | 10 | # 1. Store builds: if the latest production build's version differs from |
| 11 | | # app.config.ts, cut a new build with --auto-submit (TestFlight + |
| 12 | | # Play internal track). Bumping `version` is therefore all it takes to |
| 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 | 13 | # start the next release train — the first build of a version enters |
| 14 | 14 | # external-TestFlight beta review immediately, and later builds of the |
| 15 | | # same version auto-approve. Releasing to the App Store stays a manual |
| 16 | | # App Store Connect step. |
| 15 | # same version auto-approve until that version is released. After App |
| 16 | # Store approval, Apple closes the release train and `version` must be |
| 17 | # bumped before another iOS build can be submitted. Releasing to the App |
| 18 | # Store stays a manual App Store Connect step. |
| 17 | 19 | # 2. OTA: publish a production-channel update for each platform where at |
| 18 | 20 | # least one finished production build matches the current native |
| 19 | 21 | # fingerprint. Old-version binaries with a matching fingerprint receive |
| ... | ... | @@ -42,6 +44,10 @@ on: |
| 42 | 44 | - ios |
| 43 | 45 | - android |
| 44 | 46 | - all |
| 47 | version: |
| 48 | description: "Optional build version override (blank uses app.config.ts; an override is committed before building)" |
| 49 | required: false |
| 50 | type: string |
| 45 | 51 | message: |
| 46 | 52 | description: "OTA update message (mode=update only)" |
| 47 | 53 | required: false |
| ... | ... | @@ -89,11 +95,21 @@ jobs: |
| 89 | 95 | echo "EXPO_TOKEN is not available; skipping EAS production job." |
| 90 | 96 | fi |
| 91 | 97 | |
| 98 | - id: version_app_token |
| 99 | name: Mint release app token for version override |
| 100 | if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.version != '' |
| 101 | uses: actions/create-github-app-token@v2 |
| 102 | with: |
| 103 | app-id: ${{ secrets.RELEASE_APP_ID }} |
| 104 | private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} |
| 105 | owner: ${{ github.repository_owner }} |
| 106 | |
| 92 | 107 | - name: Checkout |
| 93 | 108 | if: steps.expo-token.outputs.present == 'true' |
| 94 | 109 | uses: actions/checkout@v6 |
| 95 | 110 | with: |
| 96 | 111 | fetch-depth: 0 |
| 112 | token: ${{ steps.version_app_token.outputs.token || github.token }} |
| 97 | 113 | # No sparse-checkout here: it makes actions/checkout fetch with |
| 98 | 114 | # --filter=blob:none, and eas-cli archives the project via |
| 99 | 115 | # `git clone --depth 1 file://<workspace>`, which fails (exit 128) |
| ... | ... | @@ -135,6 +151,65 @@ jobs: |
| 135 | 151 | EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} |
| 136 | 152 | run: eas env:pull production --non-interactive |
| 137 | 153 | |
| 154 | - name: Apply manual version override |
| 155 | if: steps.version_app_token.outcome == 'success' |
| 156 | env: |
| 157 | GH_TOKEN: ${{ steps.version_app_token.outputs.token }} |
| 158 | APP_SLUG: ${{ steps.version_app_token.outputs.app-slug }} |
| 159 | RELEASE_VERSION: ${{ inputs.version }} |
| 160 | run: | |
| 161 | if [ "$GITHUB_REF_TYPE" != "branch" ]; then |
| 162 | echo "Version overrides require dispatching this workflow from a branch; received $GITHUB_REF_TYPE '$GITHUB_REF_NAME'." >&2 |
| 163 | exit 1 |
| 164 | fi |
| 165 | if ! [[ "$RELEASE_VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}$ ]]; then |
| 166 | echo "Version override must contain two or three dot-separated integers; received '$RELEASE_VERSION'." >&2 |
| 167 | exit 1 |
| 168 | fi |
| 169 | |
| 170 | node --input-type=module -e ' |
| 171 | import fs from "node:fs"; |
| 172 | const path = "apps/mobile/app.config.ts"; |
| 173 | const source = fs.readFileSync(path, "utf8"); |
| 174 | const next = source.replace( |
| 175 | /^( version: ")[^"]+(".*)$/m, |
| 176 | `$1${process.env.RELEASE_VERSION}$2`, |
| 177 | ); |
| 178 | if (next === source && !source.includes(` version: "${process.env.RELEASE_VERSION}"`)) { |
| 179 | throw new Error("Could not update app version"); |
| 180 | } |
| 181 | fs.writeFileSync(path, next); |
| 182 | ' |
| 183 | vp fmt apps/mobile/app.config.ts |
| 184 | |
| 185 | if git diff --quiet -- apps/mobile/app.config.ts; then |
| 186 | echo "app.config.ts is already at $RELEASE_VERSION; no version commit needed." |
| 187 | exit 0 |
| 188 | fi |
| 189 | |
| 190 | user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)" |
| 191 | git config user.name "${APP_SLUG}[bot]" |
| 192 | git config user.email "${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" |
| 193 | git add apps/mobile/app.config.ts |
| 194 | git commit \ |
| 195 | -m "chore(mobile): bump app version to $RELEASE_VERSION" \ |
| 196 | -m "Co-authored-by: codex <codex@users.noreply.github.com>" |
| 197 | git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}" |
| 198 | |
| 199 | - name: Summarize manual build version |
| 200 | if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' |
| 201 | working-directory: apps/mobile |
| 202 | run: | |
| 203 | version="$(npx expo config --json --type public | jq -r '.version')" |
| 204 | { |
| 205 | echo "## Manual production build" |
| 206 | echo |
| 207 | echo "- App version: \`$version\`" |
| 208 | echo "- Platform: \`${{ inputs.platform }}\`" |
| 209 | echo |
| 210 | echo "> Apple closes an iOS release train after App Store approval. Before building iOS, confirm \`$version\` is newer than the approved App Store version." |
| 211 | } >> "$GITHUB_STEP_SUMMARY" |
| 212 | |
| 138 | 213 | - name: Build and submit (manual) |
| 139 | 214 | if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' |
| 140 | 215 | working-directory: apps/mobile |
| ... | ... | @@ -157,10 +232,8 @@ jobs: |
| 157 | 232 | |
| 158 | 233 | # No --status filter on build:list: an in-queue/in-progress build must |
| 159 | 234 | # count as existing, or every merge during the build window would cut a |
| 160 | | # duplicate. Builds started here stay attached to this serialized run so |
| 161 | | # the queued run for a later merge cannot overtake them and lose its OTA. |
| 162 | | # After an errored build, retry via workflow_dispatch mode=build — pushes |
| 163 | | # won't re-trigger it until the app version changes. |
| 235 | # duplicate. After an errored build, retry via workflow_dispatch |
| 236 | # mode=build — pushes won't re-trigger it until the app version changes. |
| 164 | 237 | - id: store_builds |
| 165 | 238 | name: Ensure store builds exist for the current app version |
| 166 | 239 | if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push' |
| ... | ... | @@ -178,8 +251,8 @@ jobs: |
| 178 | 251 | continue |
| 179 | 252 | fi |
| 180 | 253 | echo "$platform: latest production build is $latest, app.config.ts says $version — building" |
| 181 | | if eas build --platform "$platform" --profile production --auto-submit --non-interactive; then |
| 182 | | echo ":building_construction: $platform: cut production build for $version (auto-submitted)" >> "$GITHUB_STEP_SUMMARY" |
| 254 | if eas build --platform "$platform" --profile production --auto-submit --non-interactive --no-wait; then |
| 255 | echo ":building_construction: $platform: scheduled production build and submission for $version" >> "$GITHUB_STEP_SUMMARY" |
| 183 | 256 | else |
| 184 | 257 | failed=1 |
| 185 | 258 | echo ":x: $platform: production build or submission failed for $version" >> "$GITHUB_STEP_SUMMARY" |