mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 02:19:43 +00:00
Merge commit '267f3c3cf9e5acf6087a51bc4f81141f8285fb76' into staging
This commit is contained in:
commit
a46b8b95e6
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 named branch, normally `main` for a production release;
|
||||||
- a clean starting worktree with `HEAD` equal to its fetched upstream;
|
- 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;
|
- 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
|
- consistent public and OTA runtime versions across `app.json`, explicit
|
||||||
iOS `MARKETING_VERSION` (`scripts/set_version.mjs --check`);
|
`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;
|
- Node 20, JDK 17 for Android, and Xcode/CocoaPods for iOS;
|
||||||
- the configured production API/Auth/RevenueCat environment, without printing
|
- the configured production API/Auth/RevenueCat environment, without printing
|
||||||
secret values.
|
secret values.
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
const repoRoot = path.resolve(scriptDir, "../../../..");
|
const repoRoot = path.resolve(scriptDir, "../../../..");
|
||||||
const files = {
|
const files = {
|
||||||
app: path.join(repoRoot, "apps/mobile/app.json"),
|
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"),
|
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"),
|
ios: path.join(repoRoot, "apps/mobile/ios/Sauci.xcodeproj/project.pbxproj"),
|
||||||
iosInfo: path.join(repoRoot, "apps/mobile/ios/Sauci/Info.plist"),
|
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"),
|
widgetInfo: path.join(repoRoot, "apps/mobile/targets/widget/Info.plist"),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,11 +30,18 @@ function parseVersion(value, label) {
|
|||||||
return match.slice(1).map(Number);
|
return match.slice(1).map(Number);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writePreservingFinalNewline(file, contents, previous) {
|
||||||
|
fs.writeFileSync(file, previous.endsWith("\n") ? contents : contents.replace(/\n$/, ""));
|
||||||
|
}
|
||||||
|
|
||||||
function readState() {
|
function readState() {
|
||||||
const appRaw = fs.readFileSync(files.app, "utf8");
|
const appRaw = fs.readFileSync(files.app, "utf8");
|
||||||
|
const appConfigRaw = fs.readFileSync(files.appConfig, "utf8");
|
||||||
const androidRaw = fs.readFileSync(files.android, "utf8");
|
const androidRaw = fs.readFileSync(files.android, "utf8");
|
||||||
|
const androidExpoRaw = fs.readFileSync(files.androidExpo, "utf8");
|
||||||
const iosRaw = fs.readFileSync(files.ios, "utf8");
|
const iosRaw = fs.readFileSync(files.ios, "utf8");
|
||||||
const iosInfoRaw = fs.readFileSync(files.iosInfo, "utf8");
|
const iosInfoRaw = fs.readFileSync(files.iosInfo, "utf8");
|
||||||
|
const iosExpoRaw = fs.readFileSync(files.iosExpo, "utf8");
|
||||||
const widgetInfoRaw = fs.readFileSync(files.widgetInfo, "utf8");
|
const widgetInfoRaw = fs.readFileSync(files.widgetInfo, "utf8");
|
||||||
const appVersion = JSON.parse(appRaw).expo?.version;
|
const appVersion = JSON.parse(appRaw).expo?.version;
|
||||||
const androidVersions = [...androidRaw.matchAll(/\bversionName\s+["']([^"']+)["']/g)].map(
|
const androidVersions = [...androidRaw.matchAll(/\bversionName\s+["']([^"']+)["']/g)].map(
|
||||||
@@ -49,12 +59,37 @@ function readState() {
|
|||||||
};
|
};
|
||||||
const iosInfoVersion = plistVersion(iosInfoRaw, "iOS app Info.plist");
|
const iosInfoVersion = plistVersion(iosInfoRaw, "iOS app Info.plist");
|
||||||
const widgetInfoVersion = plistVersion(widgetInfoRaw, "iOS widget 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 (typeof appVersion !== "string") fail("apps/mobile/app.json has no expo.version");
|
||||||
if (androidVersions.length !== 1) fail("expected exactly one Android versionName");
|
if (androidVersions.length !== 1) fail("expected exactly one Android versionName");
|
||||||
if (iosVersions.length === 0) fail("found no iOS MARKETING_VERSION values");
|
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}`));
|
versions.forEach((version) => parseVersion(version, `configured version ${version}`));
|
||||||
if (new Set(versions).size !== 1) {
|
if (new Set(versions).size !== 1) {
|
||||||
fail(`public versions disagree: ${versions.join(", ")}`);
|
fail(`public versions disagree: ${versions.join(", ")}`);
|
||||||
@@ -62,9 +97,12 @@ function readState() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
appRaw,
|
appRaw,
|
||||||
|
appConfigRaw,
|
||||||
androidRaw,
|
androidRaw,
|
||||||
|
androidExpoRaw,
|
||||||
iosRaw,
|
iosRaw,
|
||||||
iosInfoRaw,
|
iosInfoRaw,
|
||||||
|
iosExpoRaw,
|
||||||
widgetInfoRaw,
|
widgetInfoRaw,
|
||||||
current: appVersion,
|
current: appVersion,
|
||||||
iosCount: iosVersions.length,
|
iosCount: iosVersions.length,
|
||||||
@@ -90,7 +128,7 @@ if (!check && (bumpIndex === -1) === (versionIndex === -1)) {
|
|||||||
|
|
||||||
const state = readState();
|
const state = readState();
|
||||||
if (check) {
|
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);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +153,14 @@ const androidRaw = state.androidRaw.replace(
|
|||||||
/(\bversionName\s+["'])([^"']+)(["'])/,
|
/(\bversionName\s+["'])([^"']+)(["'])/,
|
||||||
`$1${desired}$3`,
|
`$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(
|
const iosRaw = state.iosRaw.replace(
|
||||||
/(\bMARKETING_VERSION\s*=\s*)([^;]+)(;)/g,
|
/(\bMARKETING_VERSION\s*=\s*)([^;]+)(;)/g,
|
||||||
`$1${desired}$3`,
|
`$1${desired}$3`,
|
||||||
@@ -123,14 +169,21 @@ const iosInfoRaw = state.iosInfoRaw.replace(
|
|||||||
/(<key>CFBundleShortVersionString<\/key>\s*<string>)([^<]+)(<\/string>)/,
|
/(<key>CFBundleShortVersionString<\/key>\s*<string>)([^<]+)(<\/string>)/,
|
||||||
`$1${desired}$3`,
|
`$1${desired}$3`,
|
||||||
);
|
);
|
||||||
|
const iosExpoRaw = state.iosExpoRaw.replace(
|
||||||
|
/(<key>EXUpdatesRuntimeVersion<\/key>\s*<string>)([^<]+)(<\/string>)/,
|
||||||
|
`$1${desired}$3`,
|
||||||
|
);
|
||||||
const widgetInfoRaw = state.widgetInfoRaw.replace(
|
const widgetInfoRaw = state.widgetInfoRaw.replace(
|
||||||
/(<key>CFBundleShortVersionString<\/key>\s*<string>)([^<]+)(<\/string>)/,
|
/(<key>CFBundleShortVersionString<\/key>\s*<string>)([^<]+)(<\/string>)/,
|
||||||
`$1${desired}$3`,
|
`$1${desired}$3`,
|
||||||
);
|
);
|
||||||
|
|
||||||
fs.writeFileSync(files.app, appRaw);
|
writePreservingFinalNewline(files.app, appRaw, state.appRaw);
|
||||||
fs.writeFileSync(files.android, androidRaw);
|
writePreservingFinalNewline(files.appConfig, appConfigRaw, state.appConfigRaw);
|
||||||
fs.writeFileSync(files.ios, iosRaw);
|
writePreservingFinalNewline(files.android, androidRaw, state.androidRaw);
|
||||||
fs.writeFileSync(files.iosInfo, iosInfoRaw);
|
writePreservingFinalNewline(files.androidExpo, androidExpoRaw, state.androidExpoRaw);
|
||||||
fs.writeFileSync(files.widgetInfo, widgetInfoRaw);
|
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}`);
|
console.log(`updated public version ${state.current} -> ${desired}`);
|
||||||
@@ -78,12 +78,10 @@ if (process.env.RELEASE_CHANNEL) {
|
|||||||
|
|
||||||
module.exports = () => ({
|
module.exports = () => ({
|
||||||
...appJson.expo,
|
...appJson.expo,
|
||||||
// Updates are gated on the app version. A JavaScript-only change ships over
|
// Keep this explicit because EOAS validates a primitive runtime string before
|
||||||
// the air; anything that changes native code must ship a new store build with
|
// upload. The release version script updates it atomically with app.json and
|
||||||
// a bumped `version`, which starts a fresh runtime version on the server.
|
// the checked-in native runtime values.
|
||||||
runtimeVersion: {
|
runtimeVersion: '1.0.7',
|
||||||
policy: 'appVersion',
|
|
||||||
},
|
|
||||||
updates: {
|
updates: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
url: OTA_MANIFEST_URL,
|
url: OTA_MANIFEST_URL,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
const configPath = require.resolve("../../app.config");
|
const configPath = require.resolve("../../app.config");
|
||||||
|
const appJson = require("../../app.json");
|
||||||
|
|
||||||
type UpdateConfig = {
|
type UpdateConfig = {
|
||||||
|
version: string;
|
||||||
|
runtimeVersion: string;
|
||||||
updates: {
|
updates: {
|
||||||
codeSigningCertificate?: string;
|
codeSigningCertificate?: string;
|
||||||
codeSigningMetadata?: Record<string, string>;
|
codeSigningMetadata?: Record<string, string>;
|
||||||
@@ -43,4 +46,11 @@ describe("Expo OTA configuration", () => {
|
|||||||
expect(config.updates.codeSigningCertificate).toBeUndefined();
|
expect(config.updates.codeSigningCertificate).toBeUndefined();
|
||||||
expect(config.updates.codeSigningMetadata).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
|
### Runtime version
|
||||||
|
|
||||||
`apps/mobile/app.config.js` sets:
|
`apps/mobile/app.config.js` sets an explicit runtime string:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
runtimeVersion: { policy: 'appVersion' }
|
runtimeVersion: '1.0.7'
|
||||||
```
|
```
|
||||||
|
|
||||||
The runtime version is therefore the `expo.version` field in `app.json`, for
|
The release version script updates this string with `expo.version` and the
|
||||||
example `1.0.7`. An update is only offered to builds whose runtime version
|
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.
|
matches exactly.
|
||||||
|
|
||||||
The practical rule: **any change that touches native code, native dependencies,
|
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
|
JavaScript, asset, and configuration changes that do not alter the native binary
|
||||||
can ship over the air against the current `version`.
|
can ship over the air against the current `version`.
|
||||||
|
|
||||||
`appVersion` is chosen over `fingerprint` deliberately. Sauci commits its native
|
An explicit runtime string is used because EOAS requires a concrete value for
|
||||||
projects and ships several config plugins and an Apple widget target, so a
|
this checked-in bare workflow. The synchronized release script keeps it tied to
|
||||||
fingerprint would churn on native edits that never reach a device, and it would
|
the number visible in App Store Connect and Google Play without relying on Expo
|
||||||
be harder to reason about from a store release. `appVersion` ties the runtime
|
to resolve a policy during upload.
|
||||||
version to the number already visible in App Store Connect and Google Play.
|
|
||||||
|
|
||||||
## Client configuration
|
## Client configuration
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ Resolved configuration:
|
|||||||
| `updates.requestHeaders['expo-channel-name']` | `process.env.RELEASE_CHANNEL`, defaulting to `development` |
|
| `updates.requestHeaders['expo-channel-name']` | `process.env.RELEASE_CHANNEL`, defaulting to `development` |
|
||||||
| `updates.requestHeaders['expo-app-id']` | `process.env.XPREM_APP_ID` |
|
| `updates.requestHeaders['expo-app-id']` | `process.env.XPREM_APP_ID` |
|
||||||
| `updates.requestHeaders['xprem-branch']` | `''` |
|
| `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
|
`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
|
`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
|
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
|
[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
|
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
|
## Prerequisites
|
||||||
|
|
||||||
@@ -324,7 +324,15 @@ user for fresh authorization immediately before this step.
|
|||||||
|
|
||||||
Version is managed locally by EAS with `appVersionSource: "local"` in
|
Version is managed locally by EAS with `appVersionSource: "local"` in
|
||||||
`apps/mobile/eas.json`. Production builds auto-increment the platform build
|
`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:
|
To inspect or deliberately set versions:
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user