mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Merge remote-tracking branch 'origin/staging' into task/expo-56-observe
# Conflicts: # docs/releasing.md
This commit is contained in:
commit
66c25849e9
9 files changed
+411
-16
No files matched your search
@@ -3,8 +3,8 @@ id: 2026-08-30-ios-extension-version-verification
|
||||
date: 2026-08-30
|
||||
status: promoted
|
||||
scope: iOS release artifact verification
|
||||
evidence: build 45 Xcode archive warning plus direct PlistBuddy inspection of the exported IPA
|
||||
destination: .codex/skills/release-mobile/SKILL.md
|
||||
evidence: v1.0.6 exported IPA inspection and reproduced v1.0.7 Xcode build-setting mismatch
|
||||
destination: apps/mobile/scripts/release-preflight.mjs and .codex/skills/release-mobile/SKILL.md
|
||||
review_after: 2027-02-28
|
||||
---
|
||||
|
||||
@@ -21,19 +21,23 @@ and every packaged extension `Info.plist` with `PlistBuddy`.
|
||||
|
||||
## Cause
|
||||
|
||||
The archive warning described an intermediate state and was not authoritative
|
||||
for the exported package. Direct IPA inspection showed both the app and
|
||||
`LiveDrawWidget.appex` at version 1.0.6 build 45.
|
||||
The v1.0.6 archive warning described an intermediate state and was not
|
||||
authoritative for that exported package. The later v1.0.7 reproduction found a
|
||||
separate real hazard: `CURRENT_PROJECT_VERSION` in both LiveDrawWidget
|
||||
configurations can override the widget plist and package an older build number.
|
||||
|
||||
## Remediation
|
||||
|
||||
For every iOS release, inspect version and build values in the exported app and
|
||||
all packaged extensions. Reject or rebuild only when the final IPA values differ;
|
||||
do not accept or reject a release from archive warnings alone.
|
||||
Before every iOS release, require public-version parity across app config, both
|
||||
plists, and Xcode marketing settings. Require the widget Debug and Release
|
||||
`CURRENT_PROJECT_VERSION` values to equal both plists. Then inspect the exported
|
||||
app and all packaged extensions. Reject or rebuild only when the final IPA values
|
||||
differ; do not accept or reject a release from archive warnings alone.
|
||||
|
||||
## Evidence
|
||||
|
||||
`PlistBuddy` read `CFBundleShortVersionString` 1.0.6 and `CFBundleVersion` 45 from
|
||||
both packaged plists. `codesign --verify --deep --strict` passed for the app, and
|
||||
the verified IPA SHA-256 was
|
||||
`9d4779bddac4647911a3a38e4ee37cf794c2b8691e0746a041b629c62b308e7f`.
|
||||
both v1.0.6 packaged plists. The v1.0.7 build later proved a stale widget Xcode
|
||||
setting can survive plist-only review. `release-preflight.mjs` and its node test
|
||||
now reject that mismatch before EAS allocation; exported IPA inspection remains
|
||||
the final authority.
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
id: 2026-08-31-mobile-release-preflight-and-attempts
|
||||
date: 2026-08-31
|
||||
status: promoted
|
||||
scope: mobile production release preparation and local EAS builds
|
||||
evidence: v1.0.7 Android and iOS local EAS release investigation and verified artifacts
|
||||
destination: apps/mobile/scripts/release-preflight.mjs, apps/mobile/scripts/release-preflight.test.mjs, docs/releasing.md, and release skills
|
||||
review_after: 2027-02-28
|
||||
---
|
||||
|
||||
## Symptom
|
||||
|
||||
The v1.0.7 release spent repeated attempts on archive, plugin startup, package
|
||||
installation, disk-space, signing, and iOS extension-version failures before
|
||||
valid Android and iOS artifacts were accepted.
|
||||
|
||||
## Reproduction
|
||||
|
||||
Run a local production EAS build with an ignored public OTA certificate, a
|
||||
package-directory plugin path, insufficient disk space, or divergent widget
|
||||
`CURRENT_PROJECT_VERSION`. Each condition fails or produces an invalid input
|
||||
before a valid final artifact can be accepted.
|
||||
|
||||
## Cause
|
||||
|
||||
Pre-compilation setup failures were treated as rebuild failures. Source parity,
|
||||
archive inclusion, local EAS plugin shape, disk capacity, and effective iOS widget
|
||||
build settings were not checked together before EAS allocation.
|
||||
|
||||
## Remediation
|
||||
|
||||
Run the deterministic mobile release preflight before every production build.
|
||||
Classify attempts before retrying: archive, plugin, npm, and ENOSPC failures do
|
||||
not consume a native build; signing, profile, and capability failures require
|
||||
provider read-back before one replacement build. Retain work directories and logs
|
||||
at stable ignored paths. Accept only independently inspected artifacts and keep
|
||||
rejected artifacts out of the upload path.
|
||||
|
||||
The Android native input failure was a Gradle task ordering issue: Prefab inputs
|
||||
must be available before dependent native tasks. Preserve the explicit ordering
|
||||
fix rather than removing Firebase or changing dependency versions without proof.
|
||||
|
||||
## Evidence
|
||||
|
||||
The preflight node tests reject an ignored OTA certificate and an effective widget
|
||||
build-setting mismatch. The checked-in source invariants pass the preflight. The
|
||||
maintained release guide and skills record local EAS path requirements, provider
|
||||
read-back, phase-specific status language, artifact inspection, store read-back,
|
||||
and scoped upload authorization boundaries.
|
||||
@@ -43,6 +43,8 @@ Require all of the following:
|
||||
- consistent public and OTA runtime versions across `app.json`, explicit
|
||||
`app.config.js` runtime, Android, and every iOS target
|
||||
(`scripts/set_version.mjs --check`);
|
||||
- `npm run release:preflight -w @sauci/mobile`, which also checks the iOS widget
|
||||
effective build settings and OTA certificate archive inclusion;
|
||||
- Node 20, JDK 17 for Android, and Xcode/CocoaPods for iOS;
|
||||
- the configured production API/Auth/RevenueCat environment, without printing
|
||||
secret values.
|
||||
@@ -84,6 +86,15 @@ Build from the clean prepared source using the package scripts in
|
||||
versioned artifact directory. When building both platforms, build Android first,
|
||||
then iOS.
|
||||
|
||||
For local EAS, retain the working directory and logs under a stable ignored path,
|
||||
set `EAS_LOCAL_BUILD_ARTIFACTS_DIR`, and preflight available disk space before
|
||||
starting. Set `EAS_LOCAL_BUILD_PLUGIN_PATH` only to the matching plugin
|
||||
executable `bin/run`, never a package directory or a hardcoded cache location.
|
||||
Classify plugin, archive, npm, and ENOSPC failures as pre-compilation failures;
|
||||
repair and preflight them without spending another build. Make at most one
|
||||
meaningful build for each newly proven input state. After a signing, profile, or
|
||||
capability failure, read back and prove the provider correction before rebuilding.
|
||||
|
||||
After each build:
|
||||
|
||||
1. Inspect the worktree before doing anything else. Accept only the documented
|
||||
@@ -92,15 +103,19 @@ After each build:
|
||||
dependency, or native-project regeneration as a failed build.
|
||||
2. Independently inspect the binary for package/bundle identifier, public
|
||||
version, platform build number, production endpoint configuration where
|
||||
inspectable, and signing identity/fingerprint. Require the embedded public
|
||||
version to equal the requested version.
|
||||
inspectable, signing identity/fingerprint, OTA runtime/channel/certificate,
|
||||
and SHA-256. Require the embedded public version to equal the requested
|
||||
version. For Android, also inspect all required ABIs, Worklets/Reanimated/Skia
|
||||
libraries, and the release permissions. For iOS, inspect every packaged
|
||||
extension after export.
|
||||
3. Record the verified platform build number in the new release-note entry.
|
||||
4. Commit only the expected metadata and release-note update as
|
||||
`chore(<platform>): record v<version> build <number>`. The worktree must be
|
||||
clean before starting the next platform build.
|
||||
|
||||
Do not accept a successful EAS exit code as proof of a valid artifact. If either
|
||||
requested platform fails, do not tag a partial release.
|
||||
Do not accept a successful EAS exit code as proof of a valid artifact. Quarantine
|
||||
or remove rejected artifacts so only accepted paths can be offered for upload. If
|
||||
either requested platform fails, do not tag a partial release.
|
||||
|
||||
## Tag and prove the result
|
||||
|
||||
|
||||
@@ -17,6 +17,25 @@ verified EAS version metadata. Sauci tracks its native iOS and Android projects,
|
||||
so routine release builds must package those checked-in sources. Do not run
|
||||
`expo prebuild` first; native regeneration is a separate reviewed change.
|
||||
|
||||
Run `npm run release:preflight -w @sauci/mobile` before allocating an EAS build.
|
||||
It rejects inconsistent public/runtime versions, an iOS widget build-setting
|
||||
override, and a public OTA certificate that would be omitted from the archive.
|
||||
For a local EAS build, also run `npm run release:preflight:local-eas -w
|
||||
@sauci/mobile` after setting `EAS_LOCAL_BUILD_PLUGIN_PATH` to the matching
|
||||
plugin's executable `bin/run`. Do not point it at the package directory or
|
||||
hardcode a machine-local cache path.
|
||||
|
||||
Classify a failure before rebuilding. Archive, plugin-spawn, package-install, and
|
||||
disk-space failures happen before native compilation and must be repaired and
|
||||
preflighted without consuming another build. After a signing, profile, or
|
||||
capability failure, read back the provider state and prove only the affected
|
||||
input changed before rebuilding. Retain the EAS work directory, command log, and
|
||||
stable artifact path for every meaningful attempt. Never retry an ambiguous
|
||||
upload.
|
||||
|
||||
Report provider progress by phase: packaging, native compilation, submission
|
||||
queue, or store processing. Do not describe every failure as a rebuild.
|
||||
|
||||
Use the package scripts in `apps/mobile/package.json`; they create artifacts only.
|
||||
An explicit request for a mobile release, upload, or store-submission workflow
|
||||
authorizes the requested store upload within its stated platform and destination
|
||||
@@ -37,6 +56,11 @@ before uploading. If the build would be distributed automatically, report the
|
||||
exact TestFlight groups or testers. If the effect cannot be established, stop;
|
||||
changing an automatic-distribution setting is a separately authorized action.
|
||||
|
||||
App Store Connect API group association does not establish the automatic-
|
||||
distribution switch. Verify that switch in an authenticated App Store Connect UI
|
||||
session before upload; if that UI verification is unavailable, stop rather
|
||||
than inferring the distribution effect from API data.
|
||||
|
||||
Once these details match the user's stated scope, upload without requesting a
|
||||
second confirmation. Run `eas submit` with the explicit
|
||||
`--platform`, `--profile production`, `--path`, and `--wait` arguments; never
|
||||
@@ -57,3 +81,27 @@ app extension as well as the containing app and require matching
|
||||
warnings as diagnostic evidence, not the final artifact state: decide whether to
|
||||
reject or rebuild only after reading the exported IPA itself. Report the artifact,
|
||||
version/build number, upload result when authorized, and store read-back state.
|
||||
|
||||
For Android, verify the accepted AAB has all four required ABIs, the Worklets,
|
||||
Reanimated, and Skia native libraries, no broad `READ_MEDIA_IMAGES` or
|
||||
`READ_MEDIA_VIDEO` permissions, and the production OTA runtime, channel, and
|
||||
public certificate. For iOS, verify the exported IPA, not archive warnings, and
|
||||
check both the containing app and every extension.
|
||||
|
||||
For the Android native dependency ordering already tracked in Gradle, preserve
|
||||
the explicit task dependency that makes Prefab inputs available before dependent
|
||||
native tasks. Fix task validation with ordering, not Firebase removal or
|
||||
dependency-version roulette.
|
||||
|
||||
After Android upload, EAS Submit `FINISHED`, the exact internal/DRAFT/no-rollout
|
||||
submit configuration, and the retained artifact digest are the available
|
||||
authoritative read-back when a direct Play track read would require creating an
|
||||
edit. Never create an edit merely to claim read-only verification.
|
||||
|
||||
For a checked-in iOS entitlement, inspect the real Apple App ID capability state
|
||||
before a signing rebuild. The capability sync bug tracked in
|
||||
https://github.com/expo/eas-cli/issues/3986 can report success while Apple
|
||||
rejects a malformed update. Read back Associated Domains, regenerate only the
|
||||
affected profile, and decode that profile to prove the entitlement before
|
||||
rebuilding. Noninteractive managed App Store Connect key auth may require
|
||||
`EXPO_APPLE_TEAM_ID`; preserve certificates and unrelated target profiles.
|
||||
@@ -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",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '..');
|
||||
@@ -140,4 +141,8 @@ export function main(args = process.argv.slice(2), environment = process.env) {
|
||||
run('npx', ['eoas', 'publish', '--branch', branch, ...parsePublishArgs(rawPublishArgs)], exportEnv);
|
||||
}
|
||||
|
||||
if (process.argv[1] === new URL(import.meta.url).pathname) main();
|
||||
// Compare real filesystem paths. A URL pathname percent-encodes spaces, so
|
||||
// comparing it to process.argv[1] silently skips main() under any checkout
|
||||
// whose absolute path contains a space, and every ota:* script then exits 0
|
||||
// having done nothing.
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
|
||||
@@ -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 }));
|
||||
});
|
||||
@@ -40,6 +40,17 @@ An explicit request for a mobile release, upload, or store-submission workflow
|
||||
authorizes the requested store upload within its stated platform and destination
|
||||
scope. Building an artifact alone does not authorize an upload.
|
||||
|
||||
Before every production build, run the deterministic source gate:
|
||||
|
||||
```bash
|
||||
cd apps/mobile
|
||||
npm run release:preflight
|
||||
```
|
||||
|
||||
It checks public and OTA runtime version parity, iOS app/widget build parity,
|
||||
and that the public OTA certificate is tracked and not excluded by root archive
|
||||
rules. It does not replace inspection of the exported artifact.
|
||||
|
||||
## Submit a local artifact with EAS
|
||||
|
||||
The production submission credentials are managed by EAS. Never add App Store
|
||||
@@ -70,6 +81,11 @@ them before proceeding. If the effect cannot be established, stop. Changing
|
||||
automatic-distribution settings is a separate action and requires its own
|
||||
authorization.
|
||||
|
||||
App Store Connect API group association alone does not reveal whether automatic
|
||||
distribution is enabled. Verify the switch in an authenticated App Store Connect
|
||||
UI session before upload. If the UI cannot be checked, stop instead of
|
||||
inferring the effect from API data.
|
||||
|
||||
Do not use `--latest` for a local release. Before upload, verify the artifact's
|
||||
package or bundle identifier, public version, build number, signature, and
|
||||
SHA-256 digest. After upload, read back the same identity and version from Google
|
||||
@@ -92,6 +108,23 @@ may change: `app.json`, the platform native version file, and the iOS widget
|
||||
project rewrite means the source was regenerated and the artifact must be rebuilt
|
||||
from the clean tracked sources.
|
||||
|
||||
Accept an artifact only after independent inspection. Verify Android package,
|
||||
version, signer, all four ABIs, required Worklets/Reanimated/Skia libraries,
|
||||
forbidden media-read permissions, OTA runtime/channel/certificate, and SHA-256.
|
||||
Verify the iOS IPA's app and every extension version/build/signature after export.
|
||||
Quarantine or remove a rejected artifact before presenting any artifact for
|
||||
upload. Tag the exact verified build-source commit, then promote it through
|
||||
staging and main only after the release gates pass.
|
||||
|
||||
The committed Android Gradle ordering fix makes Prefab inputs available before
|
||||
dependent native tasks. Preserve that explicit dependency ordering. Do not try to
|
||||
solve Gradle task validation by removing Firebase or cycling dependency versions.
|
||||
|
||||
After Android upload, EAS Submit `FINISHED`, the exact internal/DRAFT/no-rollout
|
||||
submit configuration, and the retained artifact digest are the authoritative
|
||||
available read-back when direct Play track inspection would require creating an
|
||||
edit. Never create an edit merely to claim read-only verification.
|
||||
|
||||
### Android media permission policy
|
||||
|
||||
Sauci uses the Android system photo picker for user-selected images and videos.
|
||||
@@ -163,6 +196,23 @@ EAS_LOCAL_BUILD_WORKINGDIR=~/eas-builds \
|
||||
eas build --platform android --profile production --local
|
||||
```
|
||||
|
||||
For local builds, choose stable ignored working and artifact directories and
|
||||
retain them with the command log until the artifact is accepted or rejected.
|
||||
Check free disk space first. `ENOSPC`, EAS plugin startup, archive construction,
|
||||
and dependency installation failures occur before native compilation: fix and
|
||||
preflight those inputs without starting another build. Make one meaningful build
|
||||
per newly proven input state.
|
||||
|
||||
Report provider progress by phase: packaging, native compilation, submission
|
||||
queue, or store processing. Do not describe every failure as a rebuild.
|
||||
|
||||
If the normal EAS invocation cannot find its local-build plugin, use a matching
|
||||
CLI and plugin version and set `EAS_LOCAL_BUILD_PLUGIN_PATH` to that plugin's
|
||||
executable `bin/run`, not its package directory. Do not commit or document a
|
||||
machine-specific package-cache path. Run `npm run release:preflight:local-eas`
|
||||
after setting the variable. See https://docs.expo.dev/build-reference/local-builds/
|
||||
and https://github.com/expo/eas-cli/issues/2787.
|
||||
|
||||
### Limitations of Local Builds
|
||||
|
||||
- **Single platform only** - Can't use `--platform all`
|
||||
@@ -394,3 +444,12 @@ committed source change before running the release build.
|
||||
|
||||
Ensure your Apple Developer certificates and provisioning profiles are up to date in Xcode:
|
||||
- **Xcode** → **Settings** → **Accounts** → Download Manual Profiles
|
||||
|
||||
For a checked-in entitlement, first read the corresponding Apple App ID
|
||||
capability state. EAS capability sync can report success while Apple rejects a
|
||||
malformed update, as tracked in https://github.com/expo/eas-cli/issues/3986.
|
||||
Read back Associated Domains, regenerate only the affected profile, and decode
|
||||
the replacement profile to confirm `com.apple.developer.associated-domains`
|
||||
before rebuilding. With noninteractive managed App Store Connect key auth, set
|
||||
`EXPO_APPLE_TEAM_ID` when the command cannot otherwise select the correct team.
|
||||
Preserve existing certificates and unrelated target profiles.
|
||||
Reference in new issue
Block a user