authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-11 17:06:54+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-11 15:06:54+00:00
log3da7f9c5c35d0c1f2a1b4420fbc10016e11ce069
tree8b26a9e5590611aa451808beb3b016a43abd5afb
parentc842c6f5b90122ad88b1d2295df6a99e94954571
signature Signed by PGP key B5690EEEBB952194

fix(mobile): guard App Store release versions (#6177)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 84 insertions(+), 11 deletions(-)

.github/workflows/mobile-eas-production.yml+83-10
...@@ -8,12 +8,14 @@ name: Mobile EAS Production...@@ -8,12 +8,14 @@ name: Mobile EAS Production
8#8#
9# Every merge to main that touches the mobile app reconciles, per platform: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 from10# 1. Store builds: if the latest production build's version differs from
11# app.config.ts, cut a new build with --auto-submit (TestFlight +11# app.config.ts, cut a new build and submit it (TestFlight + Play internal
12# Play internal track). Bumping `version` is therefore all it takes to12# track). Bumping `version` is therefore all it takes to
13# start the next release train — the first build of a version enters13# start the next release train — the first build of a version enters
14# external-TestFlight beta review immediately, and later builds of the14# external-TestFlight beta review immediately, and later builds of the
15# same version auto-approve. Releasing to the App Store stays a manual15# same version auto-approve until that version is released. After App
16# App Store Connect step.16# Store approval, Apple closes the release train and `version` must be
17# bumped before another iOS build can be submitted. Releasing to the App
18# Store stays a manual App Store Connect step.
17# 2. OTA: publish a production-channel update for each platform where at19# 2. OTA: publish a production-channel update for each platform where at
18# least one finished production build matches the current native20# least one finished production build matches the current native
19# fingerprint. Old-version binaries with a matching fingerprint receive21# fingerprint. Old-version binaries with a matching fingerprint receive
...@@ -42,6 +44,10 @@ on:...@@ -42,6 +44,10 @@ on:
42 - ios44 - ios
43 - android45 - android
44 - all46 - all
47 version:
48 description: "Optional build version override (blank uses app.config.ts; an override is committed before building)"
49 required: false
50 type: string
45 message:51 message:
46 description: "OTA update message (mode=update only)"52 description: "OTA update message (mode=update only)"
47 required: false53 required: false
...@@ -89,11 +95,21 @@ jobs:...@@ -89,11 +95,21 @@ jobs:
89 echo "EXPO_TOKEN is not available; skipping EAS production job."95 echo "EXPO_TOKEN is not available; skipping EAS production job."
90 fi96 fi
9197
98 - id: version_app_token
99 name: Mint release app token for version override
100 if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.version != ''
101 uses: actions/create-github-app-token@v2
102 with:
103 app-id: ${{ secrets.RELEASE_APP_ID }}
104 private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
105 owner: ${{ github.repository_owner }}
106
92 - name: Checkout107 - name: Checkout
93 if: steps.expo-token.outputs.present == 'true'108 if: steps.expo-token.outputs.present == 'true'
94 uses: actions/checkout@v6109 uses: actions/checkout@v6
95 with:110 with:
96 fetch-depth: 0111 fetch-depth: 0
112 token: ${{ steps.version_app_token.outputs.token || github.token }}
97 # No sparse-checkout here: it makes actions/checkout fetch with113 # No sparse-checkout here: it makes actions/checkout fetch with
98 # --filter=blob:none, and eas-cli archives the project via114 # --filter=blob:none, and eas-cli archives the project via
99 # `git clone --depth 1 file://<workspace>`, which fails (exit 128)115 # `git clone --depth 1 file://<workspace>`, which fails (exit 128)
...@@ -135,6 +151,65 @@ jobs:...@@ -135,6 +151,65 @@ jobs:
135 EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}151 EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
136 run: eas env:pull production --non-interactive152 run: eas env:pull production --non-interactive
137153
154 - name: Apply manual version override
155 if: steps.version_app_token.outcome == 'success'
156 env:
157 GH_TOKEN: ${{ steps.version_app_token.outputs.token }}
158 APP_SLUG: ${{ steps.version_app_token.outputs.app-slug }}
159 RELEASE_VERSION: ${{ inputs.version }}
160 run: |
161 if [ "$GITHUB_REF_TYPE" != "branch" ]; then
162 echo "Version overrides require dispatching this workflow from a branch; received $GITHUB_REF_TYPE '$GITHUB_REF_NAME'." >&2
163 exit 1
164 fi
165 if ! [[ "$RELEASE_VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}$ ]]; then
166 echo "Version override must contain two or three dot-separated integers; received '$RELEASE_VERSION'." >&2
167 exit 1
168 fi
169
170 node --input-type=module -e '
171 import fs from "node:fs";
172 const path = "apps/mobile/app.config.ts";
173 const source = fs.readFileSync(path, "utf8");
174 const next = source.replace(
175 /^( version: ")[^"]+(".*)$/m,
176 `$1${process.env.RELEASE_VERSION}$2`,
177 );
178 if (next === source && !source.includes(` version: "${process.env.RELEASE_VERSION}"`)) {
179 throw new Error("Could not update app version");
180 }
181 fs.writeFileSync(path, next);
182 '
183 vp fmt apps/mobile/app.config.ts
184
185 if git diff --quiet -- apps/mobile/app.config.ts; then
186 echo "app.config.ts is already at $RELEASE_VERSION; no version commit needed."
187 exit 0
188 fi
189
190 user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)"
191 git config user.name "${APP_SLUG}[bot]"
192 git config user.email "${user_id}+${APP_SLUG}[bot]@users.noreply.github.com"
193 git add apps/mobile/app.config.ts
194 git commit \
195 -m "chore(mobile): bump app version to $RELEASE_VERSION" \
196 -m "Co-authored-by: codex <codex@users.noreply.github.com>"
197 git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}"
198
199 - name: Summarize manual build version
200 if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build'
201 working-directory: apps/mobile
202 run: |
203 version="$(npx expo config --json --type public | jq -r '.version')"
204 {
205 echo "## Manual production build"
206 echo
207 echo "- App version: \`$version\`"
208 echo "- Platform: \`${{ inputs.platform }}\`"
209 echo
210 echo "> Apple closes an iOS release train after App Store approval. Before building iOS, confirm \`$version\` is newer than the approved App Store version."
211 } >> "$GITHUB_STEP_SUMMARY"
212
138 - name: Build and submit (manual)213 - name: Build and submit (manual)
139 if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build'214 if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build'
140 working-directory: apps/mobile215 working-directory: apps/mobile
...@@ -157,10 +232,8 @@ jobs:...@@ -157,10 +232,8 @@ jobs:
157232
158 # No --status filter on build:list: an in-queue/in-progress build must233 # 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 a234 # count as existing, or every merge during the build window would cut a
160 # duplicate. Builds started here stay attached to this serialized run so235 # duplicate. After an errored build, retry via workflow_dispatch
161 # the queued run for a later merge cannot overtake them and lose its OTA.236 # mode=build — pushes won't re-trigger it until the app version changes.
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_builds237 - id: store_builds
165 name: Ensure store builds exist for the current app version238 name: Ensure store builds exist for the current app version
166 if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push'239 if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push'
...@@ -178,8 +251,8 @@ jobs:...@@ -178,8 +251,8 @@ jobs:
178 continue251 continue
179 fi252 fi
180 echo "$platform: latest production build is $latest, app.config.ts says $version — building"253 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; then254 if eas build --platform "$platform" --profile production --auto-submit --non-interactive --no-wait; then
182 echo ":building_construction: $platform: cut production build for $version (auto-submitted)" >> "$GITHUB_STEP_SUMMARY"255 echo ":building_construction: $platform: scheduled production build and submission for $version" >> "$GITHUB_STEP_SUMMARY"
183 else256 else
184 failed=1257 failed=1
185 echo ":x: $platform: production build or submission failed for $version" >> "$GITHUB_STEP_SUMMARY"258 echo ":x: $platform: production build or submission failed for $version" >> "$GITHUB_STEP_SUMMARY"
apps/mobile/app.config.ts+1-1
...@@ -161,7 +161,7 @@ const config: ExpoConfig = {...@@ -161,7 +161,7 @@ const config: ExpoConfig = {
161 slug: "t3-code",161 slug: "t3-code",
162 platforms: ["ios", "android"],162 platforms: ["ios", "android"],
163 scheme: variant.scheme,163 scheme: variant.scheme,
164 version: "1.0.2",164 version: "1.0.3",
165 runtimeVersion: {165 runtimeVersion: {
166 // Fingerprint (not appVersion) so an OTA only reaches binaries whose native166 // Fingerprint (not appVersion) so an OTA only reaches binaries whose native
167 // project — native deps, config plugins, AND patches/ — matches the update.167 // project — native deps, config plugins, AND patches/ — matches the update.