Skip to content

Commit 73b2e8f

Browse files
Automate production mobile EAS releases (#5609)
1 parent 5da4533 commit 73b2e8f

2 files changed

Lines changed: 306 additions & 5 deletions

File tree

.github/workflows/mobile-eas-production.yml

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,23 @@ name: Mobile EAS Production
55
# in the same OS/pnpm as the EAS build; a macOS `eas build` computes a different
66
# fingerprint (platform-specific deps + pnpm version) and errors. On this Linux
77
# 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).
825
on:
926
workflow_dispatch:
1027
inputs:
@@ -29,10 +46,30 @@ on:
2946
description: "OTA update message (mode=update only)"
3047
required: false
3148
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.
66+
concurrency:
67+
group: mobile-eas-production
68+
cancel-in-progress: false
3269

3370
jobs:
3471
production:
35-
name: EAS Production ${{ inputs.mode }}
72+
name: EAS Production ${{ github.event_name == 'push' && 'auto' || inputs.mode }}
3673
runs-on: blacksmith-8vcpu-ubuntu-2404
3774
permissions:
3875
contents: read
@@ -98,15 +135,15 @@ jobs:
98135
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
99136
run: eas env:pull production --non-interactive
100137

101-
- name: Build and submit
102-
if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'build'
138+
- name: Build and submit (manual)
139+
if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build'
103140
working-directory: apps/mobile
104141
env:
105142
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
106143
run: eas build --platform ${{ inputs.platform }} --profile production --auto-submit --non-interactive --no-wait
107144

108-
- name: Publish OTA update
109-
if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'update'
145+
- name: Publish OTA update (manual)
146+
if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'update'
110147
working-directory: apps/mobile
111148
env:
112149
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
@@ -117,3 +154,62 @@ jobs:
117154
--platform ${{ inputs.platform }} \
118155
--message "${{ inputs.message || format('Production OTA ({0})', github.sha) }}" \
119156
--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
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
name: 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.
14+
on:
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+
28+
concurrency:
29+
group: mobile-fingerprint-check-${{ github.event.pull_request.number }}
30+
cancel-in-progress: true
31+
32+
jobs:
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+
);

0 commit comments

Comments
 (0)