| 1 | name: Mobile EAS Production |
| 2 | |
| 3 | # Production builds and OTA updates run from CI (Linux) — never from a laptop. |
| 4 | # Under the fingerprint runtime-version policy the fingerprint must be computed |
| 5 | # in the same OS/pnpm as the EAS build; a macOS `eas build` computes a different |
| 6 | # fingerprint (platform-specific deps + pnpm version) and errors. On this Linux |
| 7 | # runner, with corepack pinning pnpm 10.24 in eas.json, local == build. |
| 8 | # |
| 9 | # Every merge to main that touches the mobile app reconciles, per platform: |
| 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 |
| 15 | # external-TestFlight beta review immediately, and later builds of the |
| 16 | # same version auto-approve until that version is released. After App |
| 17 | # Store approval, Apple closes the release train and `version` must be |
| 18 | # bumped before another iOS build can be submitted. Releasing to the App |
| 19 | # Store stays a manual App Store Connect step. |
| 20 | # 2. OTA: publish a production-channel update for each platform where at |
| 21 | # least one finished production build matches the current native |
| 22 | # fingerprint. Older binaries of the same major version with a matching |
| 23 | # fingerprint receive it too; the major version is part of the |
| 24 | # fingerprint (apps/mobile/fingerprint.config.js), so a new major never |
| 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. |
| 30 | # workflow_dispatch remains as a manual override for both modes (e.g. to |
| 31 | # retry an errored build or force an OTA). |
| 32 | # Manual v2-preview builds keep the production app identity and disable OTA. |
| 33 | # They auto-submit to TestFlight and the Google Play closed testing track v2. |
| 34 | # Select the v2 branch when dispatching a preview build. |
| 35 | on: |
| 36 | workflow_dispatch: |
| 37 | inputs: |
| 38 | mode: |
| 39 | description: "build (+ auto-submit to TestFlight / Google Play) or update (OTA)" |
| 40 | required: true |
| 41 | type: choice |
| 42 | default: build |
| 43 | options: |
| 44 | - build |
| 45 | - update |
| 46 | profile: |
| 47 | description: "Store build profile (v2-preview: TestFlight + Play closed track v2; build only)" |
| 48 | required: true |
| 49 | type: choice |
| 50 | default: production |
| 51 | options: |
| 52 | - production |
| 53 | - v2-preview |
| 54 | platform: |
| 55 | description: "Target platform" |
| 56 | required: true |
| 57 | type: choice |
| 58 | default: all |
| 59 | options: |
| 60 | - ios |
| 61 | - android |
| 62 | - all |
| 63 | version: |
| 64 | description: "Optional build version override (blank uses app.config.ts; an override is committed before building)" |
| 65 | required: false |
| 66 | type: string |
| 67 | message: |
| 68 | description: "OTA update message (mode=update only)" |
| 69 | required: false |
| 70 | type: string |
| 71 | push: |
| 72 | branches: [main] |
| 73 | paths: |
| 74 | - apps/mobile/** |
| 75 | - packages/client-runtime/** |
| 76 | - packages/contracts/** |
| 77 | - packages/shared/** |
| 78 | - assets/** |
| 79 | - scripts/** |
| 80 | - patches/** |
| 81 | - pnpm-lock.yaml |
| 82 | - pnpm-workspace.yaml |
| 83 | - .github/workflows/mobile-eas-production.yml |
| 84 | |
| 85 | # Serialize runs so OTAs publish in merge order. GitHub keeps at most one |
| 86 | # queued run per group, so a burst of merges collapses into one run of the |
| 87 | # newest commit — intermediate commits don't need their own OTA. |
| 88 | concurrency: |
| 89 | group: mobile-eas-production |
| 90 | cancel-in-progress: false |
| 91 | |
| 92 | jobs: |
| 93 | production: |
| 94 | name: EAS ${{ inputs.profile || 'production' }} ${{ github.event_name == 'push' && 'auto' || inputs.mode }} |
| 95 | runs-on: blacksmith-8vcpu-ubuntu-2404 |
| 96 | permissions: |
| 97 | contents: read |
| 98 | env: |
| 99 | APP_VARIANT: production |
| 100 | NODE_OPTIONS: --max-old-space-size=8192 |
| 101 | steps: |
| 102 | - name: Reject preview OTA updates |
| 103 | if: github.event_name == 'workflow_dispatch' && inputs.profile == 'v2-preview' && inputs.mode == 'update' |
| 104 | run: | |
| 105 | echo "::error::V2 previews use store builds only. Select mode=build." |
| 106 | exit 1 |
| 107 | |
| 108 | - id: expo-token |
| 109 | name: Check for EXPO_TOKEN |
| 110 | env: |
| 111 | EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} |
| 112 | run: | |
| 113 | if [ -n "$EXPO_TOKEN" ]; then |
| 114 | echo "present=true" >> "$GITHUB_OUTPUT" |
| 115 | else |
| 116 | echo "present=false" >> "$GITHUB_OUTPUT" |
| 117 | echo "EXPO_TOKEN is not available; skipping EAS production job." |
| 118 | fi |
| 119 | |
| 120 | - id: version_app_token |
| 121 | name: Mint release app token for version override |
| 122 | if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.version != '' |
| 123 | uses: actions/create-github-app-token@v2 |
| 124 | with: |
| 125 | app-id: ${{ secrets.RELEASE_APP_ID }} |
| 126 | private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} |
| 127 | owner: ${{ github.repository_owner }} |
| 128 | |
| 129 | - name: Checkout |
| 130 | if: steps.expo-token.outputs.present == 'true' |
| 131 | uses: actions/checkout@v6 |
| 132 | with: |
| 133 | fetch-depth: 0 |
| 134 | token: ${{ steps.version_app_token.outputs.token || github.token }} |
| 135 | # No sparse-checkout here: it makes actions/checkout fetch with |
| 136 | # --filter=blob:none, and eas-cli archives the project via |
| 137 | # `git clone --depth 1 file://<workspace>`, which fails (exit 128) |
| 138 | # when the partial clone can't serve the unfetched blobs. |
| 139 | |
| 140 | - name: Setup Vite+ |
| 141 | if: steps.expo-token.outputs.present == 'true' |
| 142 | uses: voidzero-dev/setup-vp@v1 |
| 143 | with: |
| 144 | node-version-file: package.json |
| 145 | cache: true |
| 146 | run-install: | |
| 147 | args: |
| 148 | - --filter=@t3tools/mobile... |
| 149 | |
| 150 | - name: Verify v2 preview source |
| 151 | if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.profile == 'v2-preview' |
| 152 | run: | |
| 153 | node --input-type=module -e ' |
| 154 | import * as environment from "./packages/contracts/src/environment.ts"; |
| 155 | if (environment.ORCHESTRATION_PROTOCOL_VERSION !== 2) { |
| 156 | throw new Error("V2 previews require a v2 source branch. Select the v2 branch when running this workflow."); |
| 157 | } |
| 158 | ' |
| 159 | |
| 160 | - name: Expose pnpm |
| 161 | if: steps.expo-token.outputs.present == 'true' |
| 162 | run: | |
| 163 | pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" |
| 164 | vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" |
| 165 | echo "$vp_pnpm_bin" >> "$GITHUB_PATH" |
| 166 | "$vp_pnpm_bin/pnpm" --version |
| 167 | |
| 168 | - name: Setup EAS |
| 169 | if: steps.expo-token.outputs.present == 'true' |
| 170 | uses: expo/expo-github-action@v8 |
| 171 | with: |
| 172 | eas-version: latest |
| 173 | token: ${{ secrets.EXPO_TOKEN }} |
| 174 | # npm, not pnpm: this only installs eas-cli into the action's own |
| 175 | # tool dir, and pnpm 11 hard-fails that install on dtrace-provider's |
| 176 | # ignored build script (no allowBuilds config outside the repo). |
| 177 | packager: npm |
| 178 | |
| 179 | - name: Pull production environment variables |
| 180 | if: steps.expo-token.outputs.present == 'true' |
| 181 | working-directory: apps/mobile |
| 182 | env: |
| 183 | EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} |
| 184 | run: eas env:pull production --non-interactive |
| 185 | |
| 186 | - name: Apply manual version override |
| 187 | if: steps.version_app_token.outcome == 'success' |
| 188 | env: |
| 189 | GH_TOKEN: ${{ steps.version_app_token.outputs.token }} |
| 190 | APP_SLUG: ${{ steps.version_app_token.outputs.app-slug }} |
| 191 | RELEASE_VERSION: ${{ inputs.version }} |
| 192 | run: | |
| 193 | if [ "$GITHUB_REF_TYPE" != "branch" ]; then |
| 194 | echo "Version overrides require dispatching this workflow from a branch; received $GITHUB_REF_TYPE '$GITHUB_REF_NAME'." >&2 |
| 195 | exit 1 |
| 196 | fi |
| 197 | if ! [[ "$RELEASE_VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}$ ]]; then |
| 198 | echo "Version override must contain two or three dot-separated integers; received '$RELEASE_VERSION'." >&2 |
| 199 | exit 1 |
| 200 | fi |
| 201 | |
| 202 | node --input-type=module -e ' |
| 203 | import fs from "node:fs"; |
| 204 | const path = "apps/mobile/app.config.ts"; |
| 205 | const source = fs.readFileSync(path, "utf8"); |
| 206 | const next = source.replace( |
| 207 | /^( version: ")[^"]+(".*)$/m, |
| 208 | `$1${process.env.RELEASE_VERSION}$2`, |
| 209 | ); |
| 210 | if (next === source && !source.includes(` version: "${process.env.RELEASE_VERSION}"`)) { |
| 211 | throw new Error("Could not update app version"); |
| 212 | } |
| 213 | fs.writeFileSync(path, next); |
| 214 | ' |
| 215 | vp fmt apps/mobile/app.config.ts |
| 216 | |
| 217 | if git diff --quiet -- apps/mobile/app.config.ts; then |
| 218 | echo "app.config.ts is already at $RELEASE_VERSION; no version commit needed." |
| 219 | exit 0 |
| 220 | fi |
| 221 | |
| 222 | user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)" |
| 223 | git config user.name "${APP_SLUG}[bot]" |
| 224 | git config user.email "${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" |
| 225 | git add apps/mobile/app.config.ts |
| 226 | git commit \ |
| 227 | -m "chore(mobile): bump app version to $RELEASE_VERSION" \ |
| 228 | -m "Co-authored-by: codex <codex@users.noreply.github.com>" |
| 229 | git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}" |
| 230 | |
| 231 | - name: Summarize manual build version |
| 232 | if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' |
| 233 | working-directory: apps/mobile |
| 234 | env: |
| 235 | MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }} |
| 236 | run: | |
| 237 | version="$(npx expo config --json --type public | jq -r '.version')" |
| 238 | { |
| 239 | echo "## Manual $MOBILE_BUILD_PROFILE build" |
| 240 | echo |
| 241 | echo "- App version: \`$version\`" |
| 242 | echo "- Platform: \`${{ inputs.platform }}\`" |
| 243 | echo |
| 244 | echo "> Apple closes an iOS release train after App Store approval. Before building iOS, confirm \`$version\` is newer than the approved App Store version." |
| 245 | } >> "$GITHUB_STEP_SUMMARY" |
| 246 | |
| 247 | - name: Build and submit (manual) |
| 248 | if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' |
| 249 | working-directory: apps/mobile |
| 250 | env: |
| 251 | EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} |
| 252 | MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }} |
| 253 | run: eas build --platform ${{ inputs.platform }} --profile "$MOBILE_BUILD_PROFILE" --auto-submit --non-interactive --no-wait |
| 254 | |
| 255 | - name: Publish OTA update (manual) |
| 256 | if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'update' |
| 257 | working-directory: apps/mobile |
| 258 | env: |
| 259 | EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} |
| 260 | run: | |
| 261 | eas update \ |
| 262 | --channel production \ |
| 263 | --environment production \ |
| 264 | --platform ${{ inputs.platform }} \ |
| 265 | --message "${{ inputs.message || format('Production OTA ({0})', github.sha) }}" \ |
| 266 | --non-interactive |
| 267 | |
| 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 |
| 279 | if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push' |
| 280 | working-directory: apps/mobile |
| 281 | env: |
| 282 | EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} |
| 283 | run: | |
| 284 | failed=0 |
| 285 | version="$(npx expo config --json --type public | jq -r '.version')" |
| 286 | message="$(git log -1 --pretty=%s | head -c 120) ($(git rev-parse --short=9 HEAD))" |
| 287 | for platform in ios android; do |
| 288 | # eas-cli prints an environment-loaded notice to stdout before the |
| 289 | # JSON even with --json, so discard everything before the document. |
| 290 | hash="$(eas fingerprint:generate --platform "$platform" --environment production --json --non-interactive | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')" |
| 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 \ |
| 299 | --channel production \ |
| 300 | --environment production \ |
| 301 | --platform "$platform" \ |
| 302 | --message "$message" \ |
| 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" |
| 333 | else |
| 334 | failed=1 |
| 335 | echo ":x: $platform: production build or submission failed for $version" >> "$GITHUB_STEP_SUMMARY" |
| 336 | fi |
| 337 | done |
| 338 | exit "$failed" |