mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
fix(ota): use explicit Expo runtime version
This commit is contained in:
1 parent
02aa6e4a75
commit
267f3c3cf9
6 files changed
+97
-27
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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(/<key>EXUpdatesRuntimeVersion<\/key>\s*<string>([^<]+)<\/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 name="expo_runtime_version">([^<]+)<\/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 name="expo_runtime_version">)([^<]+)(<\/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(
|
||||
/(<key>CFBundleShortVersionString<\/key>\s*<string>)([^<]+)(<\/string>)/,
|
||||
`$1${desired}$3`,
|
||||
);
|
||||
const iosExpoRaw = state.iosExpoRaw.replace(
|
||||
/(<key>EXUpdatesRuntimeVersion<\/key>\s*<string>)([^<]+)(<\/string>)/,
|
||||
`$1${desired}$3`,
|
||||
);
|
||||
const widgetInfoRaw = state.widgetInfoRaw.replace(
|
||||
/(<key>CFBundleShortVersionString<\/key>\s*<string>)([^<]+)(<\/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}`);
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string>;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+10
-10
@@ -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
|
||||
|
||||
+10
-2
@@ -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:
|
||||
|
||||
|
||||
Reference in new issue
Block a user