feat(pull-requests): say why a merge was refused, and let an administ… #11
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Mobile EAS Production | |
| # Production builds and OTA updates run from CI (Linux) — never from a laptop. | |
| # Under the fingerprint runtime-version policy the fingerprint must be computed | |
| # in the same OS/pnpm as the EAS build; a macOS `eas build` computes a different | |
| # fingerprint (platform-specific deps + pnpm version) and errors. On this Linux | |
| # runner, with corepack pinning pnpm 10.24 in eas.json, local == build. | |
| # | |
| # Every merge to main that touches the mobile app reconciles, per platform: | |
| # 1. Store builds: if the latest production build's version differs from | |
| # app.config.ts, cut a new build and submit it (TestFlight + Play internal | |
| # track). Bumping `version` is therefore all it takes to | |
| # start the next release train — the first build of a version enters | |
| # external-TestFlight beta review immediately, and later builds of the | |
| # same version auto-approve until that version is released. After App | |
| # Store approval, Apple closes the release train and `version` must be | |
| # bumped before another iOS build can be submitted. Releasing to the App | |
| # Store stays a manual App Store Connect step. | |
| # 2. OTA: publish a production-channel update for each platform where at | |
| # least one finished production build matches the current native | |
| # fingerprint. Old-version binaries with a matching fingerprint receive | |
| # it too. When native drift means no binary could install the update, | |
| # it is skipped and flagged in the job summary instead of published | |
| # into the void. | |
| # workflow_dispatch remains as a manual override for both modes (e.g. to | |
| # retry an errored build or force an OTA). | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| mode: | |
| description: "build (+ auto-submit to TestFlight) or update (OTA)" | |
| required: true | |
| type: choice | |
| default: build | |
| options: | |
| - build | |
| - update | |
| platform: | |
| description: "Target platform" | |
| required: true | |
| type: choice | |
| default: ios | |
| options: | |
| - ios | |
| - android | |
| - all | |
| version: | |
| description: "Optional build version override (blank uses app.config.ts; an override is committed before building)" | |
| required: false | |
| type: string | |
| message: | |
| description: "OTA update message (mode=update only)" | |
| required: false | |
| type: string | |
| push: | |
| branches: [main] | |
| paths: | |
| - apps/mobile/** | |
| - packages/client-runtime/** | |
| - packages/contracts/** | |
| - packages/shared/** | |
| - assets/** | |
| - scripts/** | |
| - patches/** | |
| - pnpm-lock.yaml | |
| - pnpm-workspace.yaml | |
| - .github/workflows/mobile-eas-production.yml | |
| # Serialize runs so OTAs publish in merge order. GitHub keeps at most one | |
| # queued run per group, so a burst of merges collapses into one run of the | |
| # newest commit — intermediate commits don't need their own OTA. | |
| concurrency: | |
| group: mobile-eas-production | |
| cancel-in-progress: false | |
| jobs: | |
| production: | |
| name: EAS Production ${{ github.event_name == 'push' && 'auto' || inputs.mode }} | |
| runs-on: blacksmith-8vcpu-ubuntu-2404 | |
| permissions: | |
| contents: read | |
| env: | |
| APP_VARIANT: production | |
| NODE_OPTIONS: --max-old-space-size=8192 | |
| steps: | |
| - id: expo-token | |
| name: Check for EXPO_TOKEN | |
| env: | |
| EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} | |
| run: | | |
| if [ -n "$EXPO_TOKEN" ]; then | |
| echo "present=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "present=false" >> "$GITHUB_OUTPUT" | |
| echo "EXPO_TOKEN is not available; skipping EAS production job." | |
| fi | |
| - id: version_app_token | |
| name: Mint release app token for version override | |
| if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.version != '' | |
| uses: actions/create-github-app-token@v2 | |
| with: | |
| app-id: ${{ secrets.RELEASE_APP_ID }} | |
| private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} | |
| owner: ${{ github.repository_owner }} | |
| - name: Checkout | |
| if: steps.expo-token.outputs.present == 'true' | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| token: ${{ steps.version_app_token.outputs.token || github.token }} | |
| # No sparse-checkout here: it makes actions/checkout fetch with | |
| # --filter=blob:none, and eas-cli archives the project via | |
| # `git clone --depth 1 file://<workspace>`, which fails (exit 128) | |
| # when the partial clone can't serve the unfetched blobs. | |
| - name: Setup Vite+ | |
| if: steps.expo-token.outputs.present == 'true' | |
| uses: voidzero-dev/setup-vp@v1 | |
| with: | |
| node-version-file: package.json | |
| cache: true | |
| run-install: | | |
| args: | |
| - --filter=@t3tools/mobile... | |
| - name: Expose pnpm | |
| if: steps.expo-token.outputs.present == 'true' | |
| run: | | |
| pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" | |
| vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" | |
| echo "$vp_pnpm_bin" >> "$GITHUB_PATH" | |
| "$vp_pnpm_bin/pnpm" --version | |
| - name: Setup EAS | |
| if: steps.expo-token.outputs.present == 'true' | |
| uses: expo/expo-github-action@v8 | |
| with: | |
| eas-version: latest | |
| token: ${{ secrets.EXPO_TOKEN }} | |
| # npm, not pnpm: this only installs eas-cli into the action's own | |
| # tool dir, and pnpm 11 hard-fails that install on dtrace-provider's | |
| # ignored build script (no allowBuilds config outside the repo). | |
| packager: npm | |
| - name: Pull production environment variables | |
| if: steps.expo-token.outputs.present == 'true' | |
| working-directory: apps/mobile | |
| env: | |
| EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} | |
| run: eas env:pull production --non-interactive | |
| - name: Apply manual version override | |
| if: steps.version_app_token.outcome == 'success' | |
| env: | |
| GH_TOKEN: ${{ steps.version_app_token.outputs.token }} | |
| APP_SLUG: ${{ steps.version_app_token.outputs.app-slug }} | |
| RELEASE_VERSION: ${{ inputs.version }} | |
| run: | | |
| if [ "$GITHUB_REF_TYPE" != "branch" ]; then | |
| echo "Version overrides require dispatching this workflow from a branch; received $GITHUB_REF_TYPE '$GITHUB_REF_NAME'." >&2 | |
| exit 1 | |
| fi | |
| if ! [[ "$RELEASE_VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}$ ]]; then | |
| echo "Version override must contain two or three dot-separated integers; received '$RELEASE_VERSION'." >&2 | |
| exit 1 | |
| fi | |
| node --input-type=module -e ' | |
| import fs from "node:fs"; | |
| const path = "apps/mobile/app.config.ts"; | |
| const source = fs.readFileSync(path, "utf8"); | |
| const next = source.replace( | |
| /^( version: ")[^"]+(".*)$/m, | |
| `$1${process.env.RELEASE_VERSION}$2`, | |
| ); | |
| if (next === source && !source.includes(` version: "${process.env.RELEASE_VERSION}"`)) { | |
| throw new Error("Could not update app version"); | |
| } | |
| fs.writeFileSync(path, next); | |
| ' | |
| vp fmt apps/mobile/app.config.ts | |
| if git diff --quiet -- apps/mobile/app.config.ts; then | |
| echo "app.config.ts is already at $RELEASE_VERSION; no version commit needed." | |
| exit 0 | |
| fi | |
| user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)" | |
| git config user.name "${APP_SLUG}[bot]" | |
| git config user.email "${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" | |
| git add apps/mobile/app.config.ts | |
| git commit \ | |
| -m "chore(mobile): bump app version to $RELEASE_VERSION" \ | |
| -m "Co-authored-by: codex <codex@users.noreply.github.com>" | |
| git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}" | |
| - name: Summarize manual build version | |
| if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' | |
| working-directory: apps/mobile | |
| run: | | |
| version="$(npx expo config --json --type public | jq -r '.version')" | |
| { | |
| echo "## Manual production build" | |
| echo | |
| echo "- App version: \`$version\`" | |
| echo "- Platform: \`${{ inputs.platform }}\`" | |
| echo | |
| echo "> Apple closes an iOS release train after App Store approval. Before building iOS, confirm \`$version\` is newer than the approved App Store version." | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Build and submit (manual) | |
| if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' | |
| working-directory: apps/mobile | |
| env: | |
| EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} | |
| run: eas build --platform ${{ inputs.platform }} --profile production --auto-submit --non-interactive --no-wait | |
| - name: Publish OTA update (manual) | |
| if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'update' | |
| working-directory: apps/mobile | |
| env: | |
| EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} | |
| run: | | |
| eas update \ | |
| --channel production \ | |
| --environment production \ | |
| --platform ${{ inputs.platform }} \ | |
| --message "${{ inputs.message || format('Production OTA ({0})', github.sha) }}" \ | |
| --non-interactive | |
| # No --status filter on build:list: an in-queue/in-progress build must | |
| # count as existing, or every merge during the build window would cut a | |
| # duplicate. After an errored build, retry via workflow_dispatch | |
| # mode=build — pushes won't re-trigger it until the app version changes. | |
| - id: store_builds | |
| name: Ensure store builds exist for the current app version | |
| if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push' | |
| continue-on-error: true | |
| working-directory: apps/mobile | |
| env: | |
| EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} | |
| run: | | |
| failed=0 | |
| version="$(npx expo config --json --type public | jq -r '.version')" | |
| for platform in ios android; do | |
| latest="$(eas build:list --platform "$platform" --build-profile production --limit 1 --json --non-interactive | jq -r '.[0].appVersion // "none"')" | |
| if [ "$latest" = "$version" ]; then | |
| echo "$platform: production build for $version already exists (or is in progress)" | |
| continue | |
| fi | |
| echo "$platform: latest production build is $latest, app.config.ts says $version — building" | |
| if eas build --platform "$platform" --profile production --auto-submit --non-interactive --no-wait; then | |
| echo ":building_construction: $platform: scheduled production build and submission for $version" >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| failed=1 | |
| echo ":x: $platform: production build or submission failed for $version" >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| done | |
| exit "$failed" | |
| - name: Publish fingerprint-gated OTA | |
| if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push' | |
| working-directory: apps/mobile | |
| env: | |
| EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} | |
| run: | | |
| message="$(git log -1 --pretty=%s | head -c 120) ($(git rev-parse --short=9 HEAD))" | |
| for platform in ios android; do | |
| # eas-cli prints an environment-loaded notice to stdout before the | |
| # JSON even with --json, so discard everything before the document. | |
| hash="$(eas fingerprint:generate --platform "$platform" --environment production --json --non-interactive | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')" | |
| matching="$(eas build:list --platform "$platform" --build-profile production --status finished --fingerprint-hash "$hash" --limit 1 --json --non-interactive | jq 'length')" | |
| if [ "$matching" -gt 0 ]; then | |
| eas update \ | |
| --channel production \ | |
| --environment production \ | |
| --platform "$platform" \ | |
| --message "$message" \ | |
| --non-interactive | |
| echo ":white_check_mark: $platform: OTA published to production (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| 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" | |
| fi | |
| done | |
| - name: Propagate store build failure | |
| if: steps.store_builds.outcome == 'failure' | |
| run: exit 1 |