Merge commit '267f3c3cf9e5acf6087a51bc4f81141f8285fb76' into staging

This commit is contained in:
jjbrunton committed 2026-08-30 21:42:44 +01:00
commit a46b8b95e6
6 files changed
+97 -27

No files matched your search

+3 -2
View File
@@ -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}`);
+4 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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: