mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
feat(release): add mobile build preflight
This commit is contained in:
1 parent
8bf93f372b
commit
5947220fc6
8 files changed
+411
-21
No files matched your search
@@ -19,6 +19,8 @@
|
||||
"release:android": "dotenv -e .env -- eas build --platform android --profile production --local",
|
||||
"release:android:preview": "dotenv -e .env -- eas build --platform android --profile preview --local",
|
||||
"release:android:upload": "bundle exec fastlane android upload",
|
||||
"release:preflight": "node scripts/release-preflight.mjs",
|
||||
"release:preflight:local-eas": "node scripts/release-preflight.mjs --local-eas",
|
||||
"ota:preflight:staging": "node scripts/ota-publish.mjs staging --preflight",
|
||||
"ota:preflight:production": "node scripts/ota-publish.mjs production --preflight",
|
||||
"ota:publish:staging": "node scripts/ota-publish.mjs staging",
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync, readFileSync, statSync, statfsSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { resolve, join } from 'node:path';
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '..');
|
||||
const repoRoot = resolve(projectDir, '../..');
|
||||
const certificatePath = 'apps/mobile/certs/certificate.pem';
|
||||
const semverPattern = /^\d+\.\d+\.\d+$/;
|
||||
const minimumFreeBuildBytes = 20 * 1024 ** 3;
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(`Mobile release preflight: ${message}`);
|
||||
}
|
||||
|
||||
function plistValue(source, key, label) {
|
||||
const values = [...source.matchAll(new RegExp(`<key>${key}</key>\\s*<string>([^<]+)</string>`, 'g'))].map((match) => match[1]);
|
||||
if (values.length !== 1) fail(`expected one ${label} ${key}`);
|
||||
return values[0];
|
||||
}
|
||||
|
||||
function allMatches(source, expression, label) {
|
||||
const values = [...source.matchAll(expression)].map((match) => match[1].trim());
|
||||
if (!values.length) fail(`found no ${label}`);
|
||||
return values;
|
||||
}
|
||||
|
||||
function gitStatus(args, root = repoRoot) {
|
||||
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
||||
if (result.error) fail(`could not run git ${args.join(' ')}: ${result.error.message}`);
|
||||
return result.status;
|
||||
}
|
||||
|
||||
export function validateReleaseSources({ root = repoRoot, isTracked } = {}) {
|
||||
const file = (relative) => readFileSync(join(root, relative), 'utf8');
|
||||
const app = JSON.parse(file('apps/mobile/app.json')).expo;
|
||||
const appConfig = file('apps/mobile/app.config.js');
|
||||
const androidGradle = file('apps/mobile/android/app/build.gradle');
|
||||
const androidStrings = file('apps/mobile/android/app/src/main/res/values/strings.xml');
|
||||
const xcode = file('apps/mobile/ios/Sauci.xcodeproj/project.pbxproj');
|
||||
const iosInfo = file('apps/mobile/ios/Sauci/Info.plist');
|
||||
const widgetInfo = file('apps/mobile/targets/widget/Info.plist');
|
||||
const iosExpo = file('apps/mobile/ios/Sauci/Supporting/Expo.plist');
|
||||
const publicVersion = app.version;
|
||||
const appConfigRuntime = allMatches(appConfig, /runtimeVersion:\s*['"]([^'"]+)['"]/g, 'explicit app.config.js runtimeVersion');
|
||||
const androidVersion = allMatches(androidGradle, /\bversionName\s+["']([^"']+)["']/g, 'Android versionName');
|
||||
const androidRuntime = allMatches(androidStrings, /<string name="expo_runtime_version">([^<]+)<\/string>/g, 'Android Expo runtime version');
|
||||
const xcodeMarketing = allMatches(xcode, /\bMARKETING_VERSION\s*=\s*([^;]+);/g, 'iOS MARKETING_VERSION settings');
|
||||
const widgetBuildSettings = [...xcode.matchAll(/CURRENT_PROJECT_VERSION\s*=\s*([^;]+);[\s\S]{0,900}?PRODUCT_BUNDLE_IDENTIFIER\s*=\s*com\.sauci\.app\.LiveDrawWidget;/g)].map((match) => match[1].trim());
|
||||
if (widgetBuildSettings.length !== 2) fail('expected Debug and Release LiveDrawWidget CURRENT_PROJECT_VERSION settings');
|
||||
|
||||
const versions = [
|
||||
publicVersion,
|
||||
...appConfigRuntime,
|
||||
...androidVersion,
|
||||
...androidRuntime,
|
||||
...xcodeMarketing,
|
||||
plistValue(iosInfo, 'CFBundleShortVersionString', 'iOS app Info.plist'),
|
||||
plistValue(widgetInfo, 'CFBundleShortVersionString', 'widget Info.plist'),
|
||||
plistValue(iosExpo, 'EXUpdatesRuntimeVersion', 'iOS Expo.plist'),
|
||||
];
|
||||
if (!versions.every((version) => semverPattern.test(version))) fail(`versions must be numeric SemVer: ${versions.join(', ')}`);
|
||||
if (new Set(versions).size !== 1) fail(`public or OTA runtime versions disagree: ${versions.join(', ')}`);
|
||||
|
||||
const appBuild = plistValue(iosInfo, 'CFBundleVersion', 'iOS app Info.plist');
|
||||
const widgetBuild = plistValue(widgetInfo, 'CFBundleVersion', 'widget Info.plist');
|
||||
if (!/^\d+$/.test(appBuild) || !/^\d+$/.test(widgetBuild)) fail('iOS build numbers must be positive integers');
|
||||
if (appBuild !== widgetBuild || widgetBuildSettings.some((build) => build !== appBuild)) {
|
||||
fail(`iOS app, widget, and effective widget Xcode build numbers disagree: app ${appBuild}; widget plist ${widgetBuild}; widget settings ${widgetBuildSettings.join(', ')}`);
|
||||
}
|
||||
|
||||
const tracked = isTracked ?? ((relative) => gitStatus(['ls-files', '--error-unmatch', relative], root) === 0);
|
||||
if (!tracked(certificatePath)) fail(`${certificatePath} must be tracked`);
|
||||
const archiveIgnore = existsSync(join(root, '.easignore')) ? '.easignore' : '.gitignore';
|
||||
if (!existsSync(join(root, archiveIgnore))) fail(`missing root ${archiveIgnore} archive rules`);
|
||||
const archiveRules = file(archiveIgnore).split(/\r?\n/).map((line) => line.trim());
|
||||
const pemRules = archiveRules.map((line, index) => line === '*.pem' ? index : -1).filter((index) => index >= 0);
|
||||
const certificateAllowlist = archiveRules.lastIndexOf(`!${certificatePath}`);
|
||||
if (!pemRules.length) fail(`${archiveIgnore} must retain the private-key-safe *.pem archive rule`);
|
||||
if (certificateAllowlist <= Math.max(...pemRules)) {
|
||||
fail(`${archiveIgnore} must later allowlist !${certificatePath} for the public OTA certificate`);
|
||||
}
|
||||
if (!file(certificatePath).trim()) fail(`${certificatePath} is empty`);
|
||||
|
||||
return { publicVersion, iosBuild: appBuild };
|
||||
}
|
||||
|
||||
export function validateLocalEas(environment = process.env) {
|
||||
const pluginPath = environment.EAS_LOCAL_BUILD_PLUGIN_PATH;
|
||||
if (!pluginPath) fail('EAS_LOCAL_BUILD_PLUGIN_PATH must name the matching local-build plugin executable bin/run');
|
||||
if (!pluginPath.endsWith('/bin/run') || !existsSync(pluginPath) || !statSync(pluginPath).isFile()) {
|
||||
fail('EAS_LOCAL_BUILD_PLUGIN_PATH must point to an existing executable bin/run, not a package directory');
|
||||
}
|
||||
if (!statSync(pluginPath).mode.toString(8).match(/[1357][0-7][0-7]$/)) fail('EAS_LOCAL_BUILD_PLUGIN_PATH bin/run is not executable');
|
||||
}
|
||||
|
||||
export function validateFreeDisk(root = repoRoot, readStats = statfsSync) {
|
||||
const stats = readStats(root);
|
||||
const freeBytes = Number(stats.bavail) * Number(stats.bsize);
|
||||
if (freeBytes < minimumFreeBuildBytes) {
|
||||
fail(`need at least 20 GiB free before a local build; found ${(freeBytes / 1024 ** 3).toFixed(1)} GiB`);
|
||||
}
|
||||
}
|
||||
|
||||
export function main(args = process.argv.slice(2), environment = process.env) {
|
||||
if (args.length > 1 || (args.length === 1 && args[0] !== '--local-eas')) {
|
||||
fail('usage: node scripts/release-preflight.mjs [--local-eas]');
|
||||
}
|
||||
const result = validateReleaseSources();
|
||||
validateFreeDisk();
|
||||
if (args.includes('--local-eas')) validateLocalEas(environment);
|
||||
console.log(`Mobile release source preflight passed for v${result.publicVersion}, iOS build ${result.iosBuild}`);
|
||||
}
|
||||
|
||||
if (process.argv[1] === new URL(import.meta.url).pathname) main();
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { validateFreeDisk, validateLocalEas, validateReleaseSources } from './release-preflight.mjs';
|
||||
|
||||
const repoRoot = join(import.meta.dirname, '..', '..', '..');
|
||||
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'sauci-release-preflight-'));
|
||||
cpSync(join(repoRoot, 'apps', 'mobile'), join(root, 'apps', 'mobile'), { recursive: true });
|
||||
cpSync(join(repoRoot, '.gitignore'), join(root, '.gitignore'));
|
||||
return root;
|
||||
}
|
||||
|
||||
function replace(root, relative, from, to) {
|
||||
const file = join(root, relative);
|
||||
writeFileSync(file, readFileSync(file, 'utf8').replace(from, to));
|
||||
}
|
||||
|
||||
function validate(root) {
|
||||
return validateReleaseSources({ root, isTracked: () => true });
|
||||
}
|
||||
|
||||
test('accepts current checked-in mobile release sources', () => {
|
||||
const root = fixture();
|
||||
try {
|
||||
const result = validate(root);
|
||||
assert.match(result.publicVersion, /^\d+\.\d+\.\d+$/);
|
||||
assert.match(result.iosBuild, /^\d+$/);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects a widget Xcode build setting that overrides its plist', () => {
|
||||
const root = fixture();
|
||||
try {
|
||||
const { iosBuild } = validate(root);
|
||||
const project = 'apps/mobile/ios/Sauci.xcodeproj/project.pbxproj';
|
||||
const source = readFileSync(join(root, project), 'utf8');
|
||||
const expected = `CURRENT_PROJECT_VERSION = ${iosBuild};`;
|
||||
const replacement = `CURRENT_PROJECT_VERSION = ${Number(iosBuild) + 1};`;
|
||||
assert.ok(source.includes(expected));
|
||||
replace(root, project, expected, replacement);
|
||||
assert.ok(readFileSync(join(root, project), 'utf8').includes(replacement));
|
||||
assert.throws(() => validate(root), /iOS app, widget, and effective widget Xcode build numbers disagree/);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects an OTA certificate without its root archive allowlist', () => {
|
||||
const root = fixture();
|
||||
try {
|
||||
const archiveRules = readFileSync(join(root, '.gitignore'), 'utf8');
|
||||
assert.ok(archiveRules.includes('!apps/mobile/certs/certificate.pem'));
|
||||
replace(root, '.gitignore', '!apps/mobile/certs/certificate.pem', '');
|
||||
assert.ok(!readFileSync(join(root, '.gitignore'), 'utf8').includes('!apps/mobile/certs/certificate.pem'));
|
||||
assert.throws(
|
||||
() => validate(root),
|
||||
/must later allowlist !apps\/mobile\/certs\/certificate\.pem/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('requires the local EAS plugin executable rather than its package directory', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'sauci-eas-plugin-'));
|
||||
const executable = join(root, 'bin-run');
|
||||
try {
|
||||
writeFileSync(executable, '#!/bin/sh\n');
|
||||
chmodSync(executable, 0o755);
|
||||
assert.throws(
|
||||
() => validateLocalEas({ EAS_LOCAL_BUILD_PLUGIN_PATH: root }),
|
||||
/executable bin\/run, not a package directory/,
|
||||
);
|
||||
const bin = join(root, 'bin');
|
||||
mkdirSync(bin);
|
||||
const run = join(bin, 'run');
|
||||
cpSync(executable, run);
|
||||
chmodSync(run, 0o755);
|
||||
validateLocalEas({ EAS_LOCAL_BUILD_PLUGIN_PATH: run });
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('requires enough disk before an expensive local build', () => {
|
||||
assert.throws(
|
||||
() => validateFreeDisk('/fixture', () => ({ bavail: 19, bsize: 1024 ** 3 })),
|
||||
/need at least 20 GiB free/,
|
||||
);
|
||||
validateFreeDisk('/fixture', () => ({ bavail: 20, bsize: 1024 ** 3 }));
|
||||
});
|
||||
Reference in new issue
Block a user