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 # Run even when a rebase or base change removes every native input from the
17 # diff, so the label can be cleared without installing Expo dependencies.
18 types: [opened, synchronize, reopened, edited]
19
20concurrency:
21 group: mobile-fingerprint-check-${{ github.event.pull_request.number }}
22 cancel-in-progress: true
23
24jobs:
25 fingerprint:
26 name: Native fingerprint diff
27 runs-on: blacksmith-8vcpu-ubuntu-2404
28 permissions:
29 contents: read
30 issues: write
31 pull-requests: write
32 env:
33 APP_VARIANT: production
34 NODE_OPTIONS: --max-old-space-size=8192
35 steps:
36 - name: Checkout
37 uses: actions/checkout@v6
38 with:
39 # The merge ref can advance after the event is queued. Pin its commit
40 # and compare against its first parent, rather than the event's base.
41 ref: ${{ github.sha }}
42 fetch-depth: 2
43
44 - id: changes
45 name: Detect native fingerprint inputs
46 run: |
47 base_sha=$(git rev-parse HEAD^1)
48 echo "base_sha=$base_sha" >> "$GITHUB_OUTPUT"
49 paths=$(git diff --no-renames --name-only "$base_sha" HEAD -- \
50 apps/mobile/ packages/client-runtime/ packages/contracts/ packages/shared/ \
51 assets/ scripts/ patches/ package.json pnpm-lock.yaml pnpm-workspace.yaml \
52 .github/workflows/mobile-fingerprint-check.yml)
53 if [[ -n "$paths" ]]; then
54 echo "relevant=true" >> "$GITHUB_OUTPUT"
55 else
56 echo "relevant=false" >> "$GITHUB_OUTPUT"
57 echo "No native fingerprint inputs changed; clearing any stale native change label." >> "$GITHUB_STEP_SUMMARY"
58 fi
59
60 - name: Setup Vite+
61 if: steps.changes.outputs.relevant == 'true'
62 uses: voidzero-dev/setup-vp@v1
63 with:
64 node-version-file: package.json
65 cache: true
66 run-install: |
67 args:
68 - --filter=@t3tools/mobile...
69
70 - name: Expose pnpm
71 if: steps.changes.outputs.relevant == 'true'
72 run: |
73 pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")"
74 vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin"
75 echo "$vp_pnpm_bin" >> "$GITHUB_PATH"
76 "$vp_pnpm_bin/pnpm" --version
77
78 - name: Fingerprint merge result
79 if: steps.changes.outputs.relevant == 'true'
80 working-directory: apps/mobile
81 run: |
82 mkdir -p "$RUNNER_TEMP/fp/head" "$RUNNER_TEMP/fp/base"
83 for platform in ios android; do
84 npx expo-updates fingerprint:generate --platform "$platform" > "$RUNNER_TEMP/fp/head/$platform.json"
85 done
86
87 - name: Fingerprint base
88 if: steps.changes.outputs.relevant == 'true'
89 env:
90 BASE_SHA: ${{ steps.changes.outputs.base_sha }}
91 run: |
92 git checkout --quiet "$BASE_SHA"
93 # Re-sync node_modules to the base commit's lockfile before
94 # fingerprinting — a dep-changing PR must not fingerprint the base
95 # against head's installed packages.
96 pnpm install --filter=@t3tools/mobile...
97 cd apps/mobile
98 for platform in ios android; do
99 npx expo-updates fingerprint:generate --platform "$platform" > "$RUNNER_TEMP/fp/base/$platform.json"
100 done
101
102 - id: compare
103 name: Compare fingerprints
104 if: steps.changes.outputs.relevant == 'true'
105 run: |
106 changed=""
107 {
108 echo "## Native fingerprint diff"
109 echo
110 for platform in ios android; do
111 head_hash="$(jq -r .hash "$RUNNER_TEMP/fp/head/$platform.json")"
112 base_hash="$(jq -r .hash "$RUNNER_TEMP/fp/base/$platform.json")"
113 if [ "$head_hash" = "$base_hash" ]; then
114 echo "- ✅ **$platform**: unchanged (\`$head_hash\`) — OTA-compatible"
115 continue
116 fi
117 changed="$changed $platform"
118 echo "- 📱 **$platform**: \`$base_hash\` → \`$head_hash\` — merging requires a new native build before OTAs work again"
119 jq -r -n \
120 --slurpfile h "$RUNNER_TEMP/fp/head/$platform.json" \
121 --slurpfile b "$RUNNER_TEMP/fp/base/$platform.json" '
122 ($b[0].sources | map({ (.filePath // .id): .hash }) | add // {}) as $bm
123 | $h[0].sources[]
124 | select($bm[(.filePath // .id)] != .hash)
125 | " - \(.type): `\(.filePath // .id)`"'
126 done
127 } >> "$GITHUB_STEP_SUMMARY"
128 echo "changed_platforms=${changed# }" >> "$GITHUB_OUTPUT"
129
130 - name: Sync native change label
131 # Fork PRs get a read-only token under pull_request; the check stays
132 # advisory there (summary only). This workflow must not move to
133 # pull_request_target — it installs and runs PR code.
134 if: github.event.pull_request.head.repo.full_name == github.repository
135 uses: actions/github-script@v8
136 env:
137 CHANGED_PLATFORMS: ${{ steps.compare.outputs.changed_platforms }}
138 with:
139 script: |
140 const managedLabel = {
141 name: "📱 Native Change",
142 color: "d93f0b",
143 description:
144 "Changes the native fingerprint; merging blocks production OTAs until a new store build ships.",
145 };
146 const nativeChanged = (process.env.CHANGED_PLATFORMS ?? "").trim() !== "";
147 const issueNumber = context.payload.pull_request.number;
148
149 try {
150 const { data: existing } = await github.rest.issues.getLabel({
151 owner: context.repo.owner,
152 repo: context.repo.repo,
153 name: managedLabel.name,
154 });
155
156 if (
157 existing.color !== managedLabel.color ||
158 (existing.description ?? "") !== managedLabel.description
159 ) {
160 await github.rest.issues.updateLabel({
161 owner: context.repo.owner,
162 repo: context.repo.repo,
163 name: managedLabel.name,
164 color: managedLabel.color,
165 description: managedLabel.description,
166 });
167 }
168 } catch (error) {
169 if (error.status !== 404) {
170 throw error;
171 }
172
173 try {
174 await github.rest.issues.createLabel({
175 owner: context.repo.owner,
176 repo: context.repo.repo,
177 name: managedLabel.name,
178 color: managedLabel.color,
179 description: managedLabel.description,
180 });
181 } catch (createError) {
182 if (createError.status !== 422) {
183 throw createError;
184 }
185 }
186 }
187
188 const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
189 owner: context.repo.owner,
190 repo: context.repo.repo,
191 issue_number: issueNumber,
192 per_page: 100,
193 });
194 const hasLabel = currentLabels.some((label) => label.name === managedLabel.name);
195
196 if (nativeChanged && !hasLabel) {
197 await github.rest.issues.addLabels({
198 owner: context.repo.owner,
199 repo: context.repo.repo,
200 issue_number: issueNumber,
201 labels: [managedLabel.name],
202 });
203 } else if (!nativeChanged && hasLabel) {
204 try {
205 await github.rest.issues.removeLabel({
206 owner: context.repo.owner,
207 repo: context.repo.repo,
208 issue_number: issueNumber,
209 name: managedLabel.name,
210 });
211 } catch (removeError) {
212 if (removeError.status !== 404) {
213 throw removeError;
214 }
215 }
216 }
217
218 core.info(
219 `PR #${issueNumber}: native fingerprint ${nativeChanged ? `changed (${process.env.CHANGED_PLATFORMS})` : "unchanged"}`,
220 );