Skip to content

feat(pull-requests): say why a merge was refused, and let an administ… #11

feat(pull-requests): say why a merge was refused, and let an administ…

feat(pull-requests): say why a merge was refused, and let an administ… #11

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