| ... | @@ -8,12 +8,14 @@ name: Mobile EAS Production | ... | @@ -8,12 +8,14 @@ name: Mobile EAS Production |
| 8 | # | 8 | # |
| 9 | # Every merge to main that touches the mobile app reconciles, per platform: | 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 | 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 + | 11 | # app.config.ts, cut a new build and submit it (TestFlight + Play internal |
| 12 | # Play internal track). Bumping `version` is therefore all it takes to | 12 | # track). Bumping `version` is therefore all it takes to |
| 13 | # start the next release train — the first build of a version enters | 13 | # start the next release train — the first build of a version enters |
| 14 | # external-TestFlight beta review immediately, and later builds of the | 14 | # external-TestFlight beta review immediately, and later builds of the |
| 15 | # same version auto-approve. Releasing to the App Store stays a manual | 15 | # same version auto-approve until that version is released. After App |
| 16 | # App Store Connect step. | 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 | # 2. OTA: publish a production-channel update for each platform where at | 19 | # 2. OTA: publish a production-channel update for each platform where at |
| 18 | # least one finished production build matches the current native | 20 | # least one finished production build matches the current native |
| 19 | # fingerprint. Old-version binaries with a matching fingerprint receive | 21 | # fingerprint. Old-version binaries with a matching fingerprint receive |
| ... | @@ -42,6 +44,10 @@ on: | ... | @@ -42,6 +44,10 @@ on: |
| 42 | - ios | 44 | - ios |
| 43 | - android | 45 | - android |
| 44 | - all | 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 | message: | 51 | message: |
| 46 | description: "OTA update message (mode=update only)" | 52 | description: "OTA update message (mode=update only)" |
| 47 | required: false | 53 | required: false |
| ... | @@ -89,11 +95,21 @@ jobs: | ... | @@ -89,11 +95,21 @@ jobs: |
| 89 | echo "EXPO_TOKEN is not available; skipping EAS production job." | 95 | echo "EXPO_TOKEN is not available; skipping EAS production job." |
| 90 | fi | 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 | - name: Checkout | 107 | - name: Checkout |
| 93 | if: steps.expo-token.outputs.present == 'true' | 108 | if: steps.expo-token.outputs.present == 'true' |
| 94 | uses: actions/checkout@v6 | 109 | uses: actions/checkout@v6 |
| 95 | with: | 110 | with: |
| 96 | fetch-depth: 0 | 111 | fetch-depth: 0 |
| | 112 | token: ${{ steps.version_app_token.outputs.token || github.token }} |
| 97 | # No sparse-checkout here: it makes actions/checkout fetch with | 113 | # No sparse-checkout here: it makes actions/checkout fetch with |
| 98 | # --filter=blob:none, and eas-cli archives the project via | 114 | # --filter=blob:none, and eas-cli archives the project via |
| 99 | # `git clone --depth 1 file://<workspace>`, which fails (exit 128) | 115 | # `git clone --depth 1 file://<workspace>`, which fails (exit 128) |
| ... | @@ -135,6 +151,65 @@ jobs: | ... | @@ -135,6 +151,65 @@ jobs: |
| 135 | EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} | 151 | EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} |
| 136 | run: eas env:pull production --non-interactive | 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 | - name: Build and submit (manual) | 213 | - name: Build and submit (manual) |
| 139 | if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' | 214 | if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' |
| 140 | working-directory: apps/mobile | 215 | working-directory: apps/mobile |
| ... | @@ -157,10 +232,8 @@ jobs: | ... | @@ -157,10 +232,8 @@ jobs: |
| 157 | | 232 | |
| 158 | # No --status filter on build:list: an in-queue/in-progress build must | 233 | # No --status filter on build:list: an in-queue/in-progress build must |
| 159 | # count as existing, or every merge during the build window would cut a | 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 | 235 | # duplicate. After an errored build, retry via workflow_dispatch |
| 161 | # the queued run for a later merge cannot overtake them and lose its OTA. | 236 | # mode=build — pushes won't re-trigger it until the app version changes. |
| 162 | # After an errored build, retry via workflow_dispatch mode=build — pushes | | |
| 163 | # won't re-trigger it until the app version changes. | | |
| 164 | - id: store_builds | 237 | - id: store_builds |
| 165 | name: Ensure store builds exist for the current app version | 238 | name: Ensure store builds exist for the current app version |
| 166 | if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push' | 239 | if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push' |
| ... | @@ -178,8 +251,8 @@ jobs: | ... | @@ -178,8 +251,8 @@ jobs: |
| 178 | continue | 251 | continue |
| 179 | fi | 252 | fi |
| 180 | echo "$platform: latest production build is $latest, app.config.ts says $version — building" | 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 | 254 | if eas build --platform "$platform" --profile production --auto-submit --non-interactive --no-wait; then |
| 182 | echo ":building_construction: $platform: cut production build for $version (auto-submitted)" >> "$GITHUB_STEP_SUMMARY" | 255 | echo ":building_construction: $platform: scheduled production build and submission for $version" >> "$GITHUB_STEP_SUMMARY" |
| 183 | else | 256 | else |
| 184 | failed=1 | 257 | failed=1 |
| 185 | echo ":x: $platform: production build or submission failed for $version" >> "$GITHUB_STEP_SUMMARY" | 258 | echo ":x: $platform: production build or submission failed for $version" >> "$GITHUB_STEP_SUMMARY" |