authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-10 04:11:22+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-10 04:11:22+02:00
log73b2e8fdd47807fcc0fc5bfe4928187035b71242
tree39446840e64eb1d7f44f6039fefb12705be26bb8
parent5da45337f7178db711624c3e7c4d69059b2b286a
signature Signed by PGP key B5690EEEBB952194

Automate production mobile EAS releases (#5609)


2 files changed, 306 insertions(+), 5 deletions(-)

.github/workflows/mobile-eas-production.yml+101-5
...@@ -5,6 +5,23 @@ name: Mobile EAS Production...@@ -5,6 +5,23 @@ name: Mobile EAS Production
5# in the same OS/pnpm as the EAS build; a macOS `eas build` computes a different5# 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 Linux6# fingerprint (platform-specific deps + pnpm version) and errors. On this Linux
7# runner, with corepack pinning pnpm 10.24 in eas.json, local == build.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: if the latest production build's version differs from
11# app.config.ts, cut a new build with --auto-submit (TestFlight +
12# Play internal track). Bumping `version` is therefore all it takes to
13# start the next release train — the first build of a version enters
14# external-TestFlight beta review immediately, and later builds of the
15# same version auto-approve. Releasing to the App Store stays a manual
16# App Store Connect step.
17# 2. OTA: publish a production-channel update for each platform where at
18# least one finished production build matches the current native
19# fingerprint. Old-version binaries with a matching fingerprint receive
20# it too. When native drift means no binary could install the update,
21# it is skipped and flagged in the job summary instead of published
22# into the void.
23# workflow_dispatch remains as a manual override for both modes (e.g. to
24# retry an errored build or force an OTA).
8on:25on:
9 workflow_dispatch:26 workflow_dispatch:
10 inputs:27 inputs:
...@@ -29,10 +46,30 @@ on:...@@ -29,10 +46,30 @@ on:
29 description: "OTA update message (mode=update only)"46 description: "OTA update message (mode=update only)"
30 required: false47 required: false
31 type: string48 type: string
49 push:
50 branches: [main]
51 paths:
52 - apps/mobile/**
53 - packages/client-runtime/**
54 - packages/contracts/**
55 - packages/shared/**
56 - assets/**
57 - scripts/**
58 - patches/**
59 - pnpm-lock.yaml
60 - pnpm-workspace.yaml
61 - .github/workflows/mobile-eas-production.yml
62
63# Serialize runs so OTAs publish in merge order. GitHub keeps at most one
64# queued run per group, so a burst of merges collapses into one run of the
65# newest commit — intermediate commits don't need their own OTA.
66concurrency:
67 group: mobile-eas-production
68 cancel-in-progress: false
3269
33jobs:70jobs:
34 production:71 production:
35 name: EAS Production ${{ inputs.mode }}72 name: EAS Production ${{ github.event_name == 'push' && 'auto' || inputs.mode }}
36 runs-on: blacksmith-8vcpu-ubuntu-240473 runs-on: blacksmith-8vcpu-ubuntu-2404
37 permissions:74 permissions:
38 contents: read75 contents: read
...@@ -98,15 +135,15 @@ jobs:...@@ -98,15 +135,15 @@ jobs:
98 EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}135 EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
99 run: eas env:pull production --non-interactive136 run: eas env:pull production --non-interactive
100137
101 - name: Build and submit138 - name: Build and submit (manual)
102 if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'build'139 if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build'
103 working-directory: apps/mobile140 working-directory: apps/mobile
104 env:141 env:
105 EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}142 EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
106 run: eas build --platform ${{ inputs.platform }} --profile production --auto-submit --non-interactive --no-wait143 run: eas build --platform ${{ inputs.platform }} --profile production --auto-submit --non-interactive --no-wait
107144
108 - name: Publish OTA update145 - name: Publish OTA update (manual)
109 if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'update'146 if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'update'
110 working-directory: apps/mobile147 working-directory: apps/mobile
111 env:148 env:
112 EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}149 EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
...@@ -117,3 +154,62 @@ jobs:...@@ -117,3 +154,62 @@ jobs:
117 --platform ${{ inputs.platform }} \154 --platform ${{ inputs.platform }} \
118 --message "${{ inputs.message || format('Production OTA ({0})', github.sha) }}" \155 --message "${{ inputs.message || format('Production OTA ({0})', github.sha) }}" \
119 --non-interactive156 --non-interactive
157
158 # 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
160 # duplicate. Builds started here stay attached to this serialized run so
161 # the queued run for a later merge cannot overtake them and lose its OTA.
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
165 name: Ensure store builds exist for the current app version
166 if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push'
167 continue-on-error: true
168 working-directory: apps/mobile
169 env:
170 EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
171 run: |
172 failed=0
173 version="$(npx expo config --json --type public | jq -r '.version')"
174 for platform in ios android; do
175 latest="$(eas build:list --platform "$platform" --build-profile production --limit 1 --json --non-interactive | jq -r '.[0].appVersion // "none"')"
176 if [ "$latest" = "$version" ]; then
177 echo "$platform: production build for $version already exists (or is in progress)"
178 continue
179 fi
180 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
182 echo ":building_construction: $platform: cut production build for $version (auto-submitted)" >> "$GITHUB_STEP_SUMMARY"
183 else
184 failed=1
185 echo ":x: $platform: production build or submission failed for $version" >> "$GITHUB_STEP_SUMMARY"
186 fi
187 done
188 exit "$failed"
189
190 - name: Publish fingerprint-gated OTA
191 if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push'
192 working-directory: apps/mobile
193 env:
194 EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
195 run: |
196 message="$(git log -1 --pretty=%s | head -c 120) ($(git rev-parse --short=9 HEAD))"
197 for platform in ios android; do
198 hash="$(eas fingerprint:generate --platform "$platform" --environment production --json --non-interactive | jq -r '.hash')"
199 matching="$(eas build:list --platform "$platform" --build-profile production --status finished --fingerprint-hash "$hash" --limit 1 --json --non-interactive | jq 'length')"
200 if [ "$matching" -gt 0 ]; then
201 eas update \
202 --channel production \
203 --environment production \
204 --platform "$platform" \
205 --message "$message" \
206 --non-interactive
207 echo ":white_check_mark: $platform: OTA published to production (fingerprint \`$hash\`)" >> "$GITHUB_STEP_SUMMARY"
208 else
209 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"
210 fi
211 done
212
213 - name: Propagate store build failure
214 if: steps.store_builds.outcome == 'failure'
215 run: exit 1
.github/workflows/mobile-fingerprint-check.yml created+205
...@@ -0,0 +1,205 @@
1name: Mobile Fingerprint Check
2
3# Detects whether a PR changes the native fingerprint — i.e. whether merging
4# it would leave main un-OTA-able until a new store build ships. Native-change
5# PRs get the "📱 Native Change" label so they can be held and merged as a
6# batch right before the next store submission, keeping main OTA-able for
7# everything else in between. (Once one native PR merges, every later merge
8# inherits the drifted fingerprint and loses OTA reach too — that is why the
9# signal has to fire before merge, not after.)
10#
11# The check is advisory: it always passes, the label is the signal. Both
12# fingerprints are computed in this one job (same OS, same corepack-pinned
13# pnpm), so the comparison is self-consistent; no EXPO_TOKEN needed.
14on:
15 pull_request:
16 paths:
17 - apps/mobile/**
18 - packages/client-runtime/**
19 - packages/contracts/**
20 - packages/shared/**
21 - assets/**
22 - scripts/**
23 - patches/**
24 - pnpm-lock.yaml
25 - pnpm-workspace.yaml
26 - .github/workflows/mobile-fingerprint-check.yml
27
28concurrency:
29 group: mobile-fingerprint-check-${{ github.event.pull_request.number }}
30 cancel-in-progress: true
31
32jobs:
33 fingerprint:
34 name: Native fingerprint diff
35 runs-on: blacksmith-8vcpu-ubuntu-2404
36 permissions:
37 contents: read
38 issues: write
39 pull-requests: write
40 env:
41 APP_VARIANT: production
42 NODE_OPTIONS: --max-old-space-size=8192
43 steps:
44 - name: Checkout
45 uses: actions/checkout@v6
46 with:
47 # Default pull_request checkout is the merge commit (PR applied on
48 # top of base), so the "head" fingerprint is the state main would
49 # actually be in after merging — stale branches compare cleanly.
50 fetch-depth: 0
51
52 - name: Setup Vite+
53 uses: voidzero-dev/setup-vp@v1
54 with:
55 node-version-file: package.json
56 cache: true
57 run-install: |
58 args:
59 - --filter=@t3tools/mobile...
60
61 - name: Expose pnpm
62 run: |
63 pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")"
64 vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin"
65 echo "$vp_pnpm_bin" >> "$GITHUB_PATH"
66 "$vp_pnpm_bin/pnpm" --version
67
68 - name: Fingerprint merge result
69 working-directory: apps/mobile
70 run: |
71 mkdir -p "$RUNNER_TEMP/fp/head" "$RUNNER_TEMP/fp/base"
72 for platform in ios android; do
73 npx expo-updates fingerprint:generate --platform "$platform" > "$RUNNER_TEMP/fp/head/$platform.json"
74 done
75
76 - name: Fingerprint base
77 run: |
78 git checkout --quiet "${{ github.event.pull_request.base.sha }}"
79 # Re-sync node_modules to the base commit's lockfile before
80 # fingerprinting — a dep-changing PR must not fingerprint the base
81 # against head's installed packages.
82 pnpm install --filter=@t3tools/mobile...
83 cd apps/mobile
84 for platform in ios android; do
85 npx expo-updates fingerprint:generate --platform "$platform" > "$RUNNER_TEMP/fp/base/$platform.json"
86 done
87
88 - id: compare
89 name: Compare fingerprints
90 run: |
91 changed=""
92 {
93 echo "## Native fingerprint diff"
94 echo
95 for platform in ios android; do
96 head_hash="$(jq -r .hash "$RUNNER_TEMP/fp/head/$platform.json")"
97 base_hash="$(jq -r .hash "$RUNNER_TEMP/fp/base/$platform.json")"
98 if [ "$head_hash" = "$base_hash" ]; then
99 echo "- ✅ **$platform**: unchanged (\`$head_hash\`) — OTA-compatible"
100 continue
101 fi
102 changed="$changed $platform"
103 echo "- 📱 **$platform**: \`$base_hash\` → \`$head_hash\` — merging requires a new native build before OTAs work again"
104 jq -r -n \
105 --slurpfile h "$RUNNER_TEMP/fp/head/$platform.json" \
106 --slurpfile b "$RUNNER_TEMP/fp/base/$platform.json" '
107 ($b[0].sources | map({ (.filePath // .id): .hash }) | add // {}) as $bm
108 | $h[0].sources[]
109 | select($bm[(.filePath // .id)] != .hash)
110 | " - \(.type): `\(.filePath // .id)`"'
111 done
112 } >> "$GITHUB_STEP_SUMMARY"
113 echo "changed_platforms=${changed# }" >> "$GITHUB_OUTPUT"
114
115 - name: Sync native change label
116 # Fork PRs get a read-only token under pull_request; the check stays
117 # advisory there (summary only). This workflow must not move to
118 # pull_request_target — it installs and runs PR code.
119 if: github.event.pull_request.head.repo.full_name == github.repository
120 uses: actions/github-script@v8
121 env:
122 CHANGED_PLATFORMS: ${{ steps.compare.outputs.changed_platforms }}
123 with:
124 script: |
125 const managedLabel = {
126 name: "📱 Native Change",
127 color: "d93f0b",
128 description:
129 "Changes the native fingerprint; merging blocks production OTAs until a new store build ships.",
130 };
131 const nativeChanged = (process.env.CHANGED_PLATFORMS ?? "").trim() !== "";
132 const issueNumber = context.payload.pull_request.number;
133
134 try {
135 const { data: existing } = await github.rest.issues.getLabel({
136 owner: context.repo.owner,
137 repo: context.repo.repo,
138 name: managedLabel.name,
139 });
140
141 if (
142 existing.color !== managedLabel.color ||
143 (existing.description ?? "") !== managedLabel.description
144 ) {
145 await github.rest.issues.updateLabel({
146 owner: context.repo.owner,
147 repo: context.repo.repo,
148 name: managedLabel.name,
149 color: managedLabel.color,
150 description: managedLabel.description,
151 });
152 }
153 } catch (error) {
154 if (error.status !== 404) {
155 throw error;
156 }
157
158 try {
159 await github.rest.issues.createLabel({
160 owner: context.repo.owner,
161 repo: context.repo.repo,
162 name: managedLabel.name,
163 color: managedLabel.color,
164 description: managedLabel.description,
165 });
166 } catch (createError) {
167 if (createError.status !== 422) {
168 throw createError;
169 }
170 }
171 }
172
173 const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
174 owner: context.repo.owner,
175 repo: context.repo.repo,
176 issue_number: issueNumber,
177 per_page: 100,
178 });
179 const hasLabel = currentLabels.some((label) => label.name === managedLabel.name);
180
181 if (nativeChanged && !hasLabel) {
182 await github.rest.issues.addLabels({
183 owner: context.repo.owner,
184 repo: context.repo.repo,
185 issue_number: issueNumber,
186 labels: [managedLabel.name],
187 });
188 } else if (!nativeChanged && hasLabel) {
189 try {
190 await github.rest.issues.removeLabel({
191 owner: context.repo.owner,
192 repo: context.repo.repo,
193 issue_number: issueNumber,
194 name: managedLabel.name,
195 });
196 } catch (removeError) {
197 if (removeError.status !== 404) {
198 throw removeError;
199 }
200 }
201 }
202
203 core.info(
204 `PR #${issueNumber}: native fingerprint ${nativeChanged ? `changed (${process.env.CHANGED_PLATFORMS})` : "unchanged"}`,
205 );