mirror of
https://github.com/VibedByKaKi/t3-code-android-nightly.git
synced 2026-10-10 04:11:15 +02:00
323 lines
14 KiB
YAML
323 lines
14 KiB
YAML
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. Older binaries of the same major version with a matching
|
|
# fingerprint receive it too; the major version is part of the
|
|
# fingerprint (apps/mobile/fingerprint.config.js), so a new major never
|
|
# reaches the previous major's store binaries over the air. 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).
|
|
# Manual v2-preview builds keep the production app identity and disable OTA.
|
|
# They auto-submit to TestFlight and the Google Play closed testing track v2.
|
|
# Select the v2 branch when dispatching a preview build.
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
mode:
|
|
description: "build (+ auto-submit to TestFlight / Google Play) or update (OTA)"
|
|
required: true
|
|
type: choice
|
|
default: build
|
|
options:
|
|
- build
|
|
- update
|
|
profile:
|
|
description: "Store build profile (v2-preview: TestFlight + Play closed track v2; build only)"
|
|
required: true
|
|
type: choice
|
|
default: production
|
|
options:
|
|
- production
|
|
- v2-preview
|
|
platform:
|
|
description: "Target platform"
|
|
required: true
|
|
type: choice
|
|
default: all
|
|
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 ${{ inputs.profile || '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:
|
|
- name: Reject preview OTA updates
|
|
if: github.event_name == 'workflow_dispatch' && inputs.profile == 'v2-preview' && inputs.mode == 'update'
|
|
run: |
|
|
echo "::error::V2 previews use store builds only. Select mode=build."
|
|
exit 1
|
|
|
|
- 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: Verify v2 preview source
|
|
if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.profile == 'v2-preview'
|
|
run: |
|
|
node --input-type=module -e '
|
|
import * as environment from "./packages/contracts/src/environment.ts";
|
|
if (environment.ORCHESTRATION_PROTOCOL_VERSION !== 2) {
|
|
throw new Error("V2 previews require a v2 source branch. Select the v2 branch when running this workflow.");
|
|
}
|
|
'
|
|
|
|
- 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
|
|
env:
|
|
MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }}
|
|
run: |
|
|
version="$(npx expo config --json --type public | jq -r '.version')"
|
|
{
|
|
echo "## Manual $MOBILE_BUILD_PROFILE 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 }}
|
|
MOBILE_BUILD_PROFILE: ${{ inputs.profile || 'production' }}
|
|
run: eas build --platform ${{ inputs.platform }} --profile "$MOBILE_BUILD_PROFILE" --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
|