From 267f3c3cf9e5acf6087a51bc4f81141f8285fb76 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sun, 30 Aug 2026 21:37:28 +0100 Subject: [PATCH] fix(ota): use explicit Expo runtime version --- .codex/skills/generate-release/SKILL.md | 5 +- .../generate-release/scripts/set_version.mjs | 67 +++++++++++++++++-- apps/mobile/app.config.js | 10 ++- apps/mobile/src/__tests__/appConfig.test.ts | 10 +++ docs/mobile-ota.md | 20 +++--- docs/releasing.md | 12 +++- 6 files changed, 97 insertions(+), 27 deletions(-) diff --git a/.codex/skills/generate-release/SKILL.md b/.codex/skills/generate-release/SKILL.md index 4643851..f87a95c 100644 --- a/.codex/skills/generate-release/SKILL.md +++ b/.codex/skills/generate-release/SKILL.md @@ -36,8 +36,9 @@ Require all of the following: - a named branch, normally `main` for a production release; - a clean starting worktree with `HEAD` equal to its fetched upstream; - the previous reachable `v[0-9]*` release tag and no collision for the next tag; -- consistent public versions across `app.json`, Android `versionName`, and every - iOS `MARKETING_VERSION` (`scripts/set_version.mjs --check`); +- consistent public and OTA runtime versions across `app.json`, explicit + `app.config.js` runtime, Android, and every iOS target + (`scripts/set_version.mjs --check`); - Node 20, JDK 17 for Android, and Xcode/CocoaPods for iOS; - the configured production API/Auth/RevenueCat environment, without printing secret values. diff --git a/.codex/skills/generate-release/scripts/set_version.mjs b/.codex/skills/generate-release/scripts/set_version.mjs index b2405e5..0a8dc87 100755 --- a/.codex/skills/generate-release/scripts/set_version.mjs +++ b/.codex/skills/generate-release/scripts/set_version.mjs @@ -8,9 +8,12 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, "../../../.."); const files = { app: path.join(repoRoot, "apps/mobile/app.json"), + appConfig: path.join(repoRoot, "apps/mobile/app.config.js"), android: path.join(repoRoot, "apps/mobile/android/app/build.gradle"), + androidExpo: path.join(repoRoot, "apps/mobile/android/app/src/main/res/values/strings.xml"), ios: path.join(repoRoot, "apps/mobile/ios/Sauci.xcodeproj/project.pbxproj"), iosInfo: path.join(repoRoot, "apps/mobile/ios/Sauci/Info.plist"), + iosExpo: path.join(repoRoot, "apps/mobile/ios/Sauci/Supporting/Expo.plist"), widgetInfo: path.join(repoRoot, "apps/mobile/targets/widget/Info.plist"), }; @@ -27,11 +30,18 @@ function parseVersion(value, label) { return match.slice(1).map(Number); } +function writePreservingFinalNewline(file, contents, previous) { + fs.writeFileSync(file, previous.endsWith("\n") ? contents : contents.replace(/\n$/, "")); +} + function readState() { const appRaw = fs.readFileSync(files.app, "utf8"); + const appConfigRaw = fs.readFileSync(files.appConfig, "utf8"); const androidRaw = fs.readFileSync(files.android, "utf8"); + const androidExpoRaw = fs.readFileSync(files.androidExpo, "utf8"); const iosRaw = fs.readFileSync(files.ios, "utf8"); const iosInfoRaw = fs.readFileSync(files.iosInfo, "utf8"); + const iosExpoRaw = fs.readFileSync(files.iosExpo, "utf8"); const widgetInfoRaw = fs.readFileSync(files.widgetInfo, "utf8"); const appVersion = JSON.parse(appRaw).expo?.version; const androidVersions = [...androidRaw.matchAll(/\bversionName\s+["']([^"']+)["']/g)].map( @@ -49,12 +59,37 @@ function readState() { }; const iosInfoVersion = plistVersion(iosInfoRaw, "iOS app Info.plist"); const widgetInfoVersion = plistVersion(widgetInfoRaw, "iOS widget Info.plist"); + const expoRuntimeVersion = (raw, label) => { + const versions = [...raw.matchAll(/EXUpdatesRuntimeVersion<\/key>\s*([^<]+)<\/string>/g)].map( + (match) => match[1], + ); + if (versions.length !== 1) fail(`expected exactly one ${label} Expo runtime version`); + return versions[0]; + }; + const appConfigRuntimeVersions = [...appConfigRaw.matchAll(/runtimeVersion:\s*['"]([^'"]+)['"]/g)].map( + (match) => match[1], + ); + const androidRuntimeVersions = [...androidExpoRaw.matchAll(/([^<]+)<\/string>/g)].map( + (match) => match[1], + ); + if (appConfigRuntimeVersions.length !== 1) fail("expected exactly one explicit app.config.js runtimeVersion"); + if (androidRuntimeVersions.length !== 1) fail("expected exactly one Android Expo runtime version"); + const iosExpoVersion = expoRuntimeVersion(iosExpoRaw, "iOS"); if (typeof appVersion !== "string") fail("apps/mobile/app.json has no expo.version"); if (androidVersions.length !== 1) fail("expected exactly one Android versionName"); if (iosVersions.length === 0) fail("found no iOS MARKETING_VERSION values"); - const versions = [appVersion, ...androidVersions, ...iosVersions, iosInfoVersion, widgetInfoVersion]; + const versions = [ + appVersion, + ...androidVersions, + ...iosVersions, + iosInfoVersion, + widgetInfoVersion, + appConfigRuntimeVersions[0], + androidRuntimeVersions[0], + iosExpoVersion, + ]; versions.forEach((version) => parseVersion(version, `configured version ${version}`)); if (new Set(versions).size !== 1) { fail(`public versions disagree: ${versions.join(", ")}`); @@ -62,9 +97,12 @@ function readState() { return { appRaw, + appConfigRaw, androidRaw, + androidExpoRaw, iosRaw, iosInfoRaw, + iosExpoRaw, widgetInfoRaw, current: appVersion, iosCount: iosVersions.length, @@ -90,7 +128,7 @@ if (!check && (bumpIndex === -1) === (versionIndex === -1)) { const state = readState(); if (check) { - console.log(`public version ${state.current} is consistent across app.json, Android, and ${state.iosCount} iOS settings`); + console.log(`public and OTA runtime version ${state.current} is consistent across app.json, Android, and ${state.iosCount} iOS settings`); process.exit(0); } @@ -115,6 +153,14 @@ const androidRaw = state.androidRaw.replace( /(\bversionName\s+["'])([^"']+)(["'])/, `$1${desired}$3`, ); +const appConfigRaw = state.appConfigRaw.replace( + /(runtimeVersion:\s*['"])([^'"]+)(['"])/, + `$1${desired}$3`, +); +const androidExpoRaw = state.androidExpoRaw.replace( + /()([^<]+)(<\/string>)/, + `$1${desired}$3`, +); const iosRaw = state.iosRaw.replace( /(\bMARKETING_VERSION\s*=\s*)([^;]+)(;)/g, `$1${desired}$3`, @@ -123,14 +169,21 @@ const iosInfoRaw = state.iosInfoRaw.replace( /(CFBundleShortVersionString<\/key>\s*)([^<]+)(<\/string>)/, `$1${desired}$3`, ); +const iosExpoRaw = state.iosExpoRaw.replace( + /(EXUpdatesRuntimeVersion<\/key>\s*)([^<]+)(<\/string>)/, + `$1${desired}$3`, +); const widgetInfoRaw = state.widgetInfoRaw.replace( /(CFBundleShortVersionString<\/key>\s*)([^<]+)(<\/string>)/, `$1${desired}$3`, ); -fs.writeFileSync(files.app, appRaw); -fs.writeFileSync(files.android, androidRaw); -fs.writeFileSync(files.ios, iosRaw); -fs.writeFileSync(files.iosInfo, iosInfoRaw); -fs.writeFileSync(files.widgetInfo, widgetInfoRaw); +writePreservingFinalNewline(files.app, appRaw, state.appRaw); +writePreservingFinalNewline(files.appConfig, appConfigRaw, state.appConfigRaw); +writePreservingFinalNewline(files.android, androidRaw, state.androidRaw); +writePreservingFinalNewline(files.androidExpo, androidExpoRaw, state.androidExpoRaw); +writePreservingFinalNewline(files.ios, iosRaw, state.iosRaw); +writePreservingFinalNewline(files.iosInfo, iosInfoRaw, state.iosInfoRaw); +writePreservingFinalNewline(files.iosExpo, iosExpoRaw, state.iosExpoRaw); +writePreservingFinalNewline(files.widgetInfo, widgetInfoRaw, state.widgetInfoRaw); console.log(`updated public version ${state.current} -> ${desired}`); diff --git a/apps/mobile/app.config.js b/apps/mobile/app.config.js index 4615709..be4f5b6 100644 --- a/apps/mobile/app.config.js +++ b/apps/mobile/app.config.js @@ -78,12 +78,10 @@ if (process.env.RELEASE_CHANNEL) { module.exports = () => ({ ...appJson.expo, - // Updates are gated on the app version. A JavaScript-only change ships over - // the air; anything that changes native code must ship a new store build with - // a bumped `version`, which starts a fresh runtime version on the server. - runtimeVersion: { - policy: 'appVersion', - }, + // Keep this explicit because EOAS validates a primitive runtime string before + // upload. The release version script updates it atomically with app.json and + // the checked-in native runtime values. + runtimeVersion: '1.0.7', updates: { enabled: true, url: OTA_MANIFEST_URL, diff --git a/apps/mobile/src/__tests__/appConfig.test.ts b/apps/mobile/src/__tests__/appConfig.test.ts index 43a995b..466708f 100644 --- a/apps/mobile/src/__tests__/appConfig.test.ts +++ b/apps/mobile/src/__tests__/appConfig.test.ts @@ -1,6 +1,9 @@ const configPath = require.resolve("../../app.config"); +const appJson = require("../../app.json"); type UpdateConfig = { + version: string; + runtimeVersion: string; updates: { codeSigningCertificate?: string; codeSigningMetadata?: Record; @@ -43,4 +46,11 @@ describe("Expo OTA configuration", () => { expect(config.updates.codeSigningCertificate).toBeUndefined(); expect(config.updates.codeSigningMetadata).toBeUndefined(); }); + + it("resolves an explicit runtime version matching the public version", () => { + const config = resolveConfig({ RELEASE_CHANNEL: "production" }); + + expect(config.runtimeVersion).toBe(appJson.expo.version); + expect(config.runtimeVersion).toBe(config.version); + }); }); diff --git a/docs/mobile-ota.md b/docs/mobile-ota.md index 281bbbf..ec6ca7d 100644 --- a/docs/mobile-ota.md +++ b/docs/mobile-ota.md @@ -47,14 +47,15 @@ Channels. The client cannot create them. ### Runtime version -`apps/mobile/app.config.js` sets: +`apps/mobile/app.config.js` sets an explicit runtime string: ```js -runtimeVersion: { policy: 'appVersion' } +runtimeVersion: '1.0.7' ``` -The runtime version is therefore the `expo.version` field in `app.json`, for -example `1.0.7`. An update is only offered to builds whose runtime version +The release version script updates this string with `expo.version` and the +checked-in iOS and Android runtime values, so it remains the same value as the +public app version. An update is only offered to builds whose runtime version matches exactly. The practical rule: **any change that touches native code, native dependencies, @@ -64,11 +65,10 @@ so old builds stop receiving updates that assume the new native surface. JavaScript, asset, and configuration changes that do not alter the native binary can ship over the air against the current `version`. -`appVersion` is chosen over `fingerprint` deliberately. Sauci commits its native -projects and ships several config plugins and an Apple widget target, so a -fingerprint would churn on native edits that never reach a device, and it would -be harder to reason about from a store release. `appVersion` ties the runtime -version to the number already visible in App Store Connect and Google Play. +An explicit runtime string is used because EOAS requires a concrete value for +this checked-in bare workflow. The synchronized release script keeps it tied to +the number visible in App Store Connect and Google Play without relying on Expo +to resolve a policy during upload. ## Client configuration @@ -90,7 +90,7 @@ Resolved configuration: | `updates.requestHeaders['expo-channel-name']` | `process.env.RELEASE_CHANNEL`, defaulting to `development` | | `updates.requestHeaders['expo-app-id']` | `process.env.XPREM_APP_ID` | | `updates.requestHeaders['xprem-branch']` | `''` | -| `runtimeVersion` | `{ policy: 'appVersion' }` | +| `runtimeVersion` | Explicit public version, currently `1.0.7` | `expo-updates` only sends request headers that were declared at build time, so `xprem-branch` is declared even though it is empty. Removing it would not disable diff --git a/docs/releasing.md b/docs/releasing.md index faaba30..5f9ec96 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -5,7 +5,7 @@ This guide covers local release builds for Android and iOS. JavaScript-only changes can also ship over the air between store releases. See [Mobile over-the-air updates](mobile-ota.md). Anything that changes native code still requires a store build with a bumped `expo.version`, because the OTA -runtime version follows the app version. +explicit runtime version is synchronized with the app version. ## Prerequisites @@ -324,7 +324,15 @@ user for fresh authorization immediately before this step. Version is managed locally by EAS with `appVersionSource: "local"` in `apps/mobile/eas.json`. Production builds auto-increment the platform build -number and synchronize the tracked native version file. +number and synchronize the tracked native version file. For a public-version +bump, use the release version script rather than editing individual sources: it +updates and verifies `app.json`, the explicit Expo runtime string, and the +checked-in iOS and Android public/runtime values together. + +```bash +node .codex/skills/generate-release/scripts/set_version.mjs --version 1.0.7 +node .codex/skills/generate-release/scripts/set_version.mjs --check +``` To inspect or deliberately set versions: