From 5947220fc692228e4c14a8542438783ebf7576b7 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Mon, 31 Aug 2026 10:10:43 +0100 Subject: [PATCH 1/2] feat(release): add mobile build preflight --- ...8-30-ios-extension-version-verification.md | 26 ++-- ...1-mobile-release-preflight-and-attempts.md | 49 ++++++++ .codex/skills/generate-release/SKILL.md | 23 +++- .codex/skills/release-mobile/SKILL.md | 48 ++++++++ apps/mobile/package.json | 2 + apps/mobile/scripts/release-preflight.mjs | 116 ++++++++++++++++++ .../mobile/scripts/release-preflight.test.mjs | 97 +++++++++++++++ docs/releasing.md | 71 ++++++++++- 8 files changed, 411 insertions(+), 21 deletions(-) create mode 100644 .agents/learnings/promoted/2026-08-31-mobile-release-preflight-and-attempts.md create mode 100644 apps/mobile/scripts/release-preflight.mjs create mode 100644 apps/mobile/scripts/release-preflight.test.mjs diff --git a/.agents/learnings/promoted/2026-08-30-ios-extension-version-verification.md b/.agents/learnings/promoted/2026-08-30-ios-extension-version-verification.md index d8497c0..fe100fe 100644 --- a/.agents/learnings/promoted/2026-08-30-ios-extension-version-verification.md +++ b/.agents/learnings/promoted/2026-08-30-ios-extension-version-verification.md @@ -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. diff --git a/.agents/learnings/promoted/2026-08-31-mobile-release-preflight-and-attempts.md b/.agents/learnings/promoted/2026-08-31-mobile-release-preflight-and-attempts.md new file mode 100644 index 0000000..1352518 --- /dev/null +++ b/.agents/learnings/promoted/2026-08-31-mobile-release-preflight-and-attempts.md @@ -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. diff --git a/.codex/skills/generate-release/SKILL.md b/.codex/skills/generate-release/SKILL.md index 25a6477..b67ee2d 100644 --- a/.codex/skills/generate-release/SKILL.md +++ b/.codex/skills/generate-release/SKILL.md @@ -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(): record v build `. 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 diff --git a/.codex/skills/release-mobile/SKILL.md b/.codex/skills/release-mobile/SKILL.md index d1ff6be..d37011d 100644 --- a/.codex/skills/release-mobile/SKILL.md +++ b/.codex/skills/release-mobile/SKILL.md @@ -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. diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 8e5e88c..385e7b6 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -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", diff --git a/apps/mobile/scripts/release-preflight.mjs b/apps/mobile/scripts/release-preflight.mjs new file mode 100644 index 0000000..67e6c61 --- /dev/null +++ b/apps/mobile/scripts/release-preflight.mjs @@ -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}\\s*([^<]+)`, '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>/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(); diff --git a/apps/mobile/scripts/release-preflight.test.mjs b/apps/mobile/scripts/release-preflight.test.mjs new file mode 100644 index 0000000..192c7b4 --- /dev/null +++ b/apps/mobile/scripts/release-preflight.test.mjs @@ -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 })); +}); diff --git a/docs/releasing.md b/docs/releasing.md index cec20a2..b8552be 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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. @@ -158,10 +191,27 @@ Useful for debugging build issues: Example with debugging enabled: ```bash -EAS_LOCAL_BUILD_SKIP_CLEANUP=1 \ -EAS_LOCAL_BUILD_WORKINGDIR=~/eas-builds \ -eas build --platform android --profile production --local -``` +EAS_LOCAL_BUILD_SKIP_CLEANUP=1 \ +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 @@ -389,5 +439,14 @@ committed source change before running the release build. ### iOS Signing Issues -Ensure your Apple Developer certificates and provisioning profiles are up to date in Xcode: -- **Xcode** → **Settings** → **Accounts** → Download Manual Profiles +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. From aa062c9fd0c5e4962bd078b8106bcc58c0d72482 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Mon, 31 Aug 2026 11:09:38 +0100 Subject: [PATCH 2/2] fix(mobile): make ota-publish actually run under a path with spaces The entrypoint guard compared process.argv[1] against new URL(import.meta.url).pathname. A URL pathname percent-encodes spaces, so in any checkout whose absolute path contains one the comparison never matched and main() never ran. Every ota:preflight:*, ota:publish:*, and ota:doctor:* script then exited 0 having done absolutely nothing, which is indistinguishable from success. A publish that silently no-ops is worse than one that fails: it reports a shipped update that never left the machine. Compare resolved filesystem paths instead. Verified from a checkout whose path contains a space: before the change `node scripts/ota-publish.mjs production --preflight` exited 0 with no output; after it runs a real export for both platforms and prints the preflight confirmation. Argument validation still rejects unknown targets and unsupported EOAS arguments. Co-Authored-By: Claude Opus 5 --- apps/mobile/scripts/ota-publish.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/mobile/scripts/ota-publish.mjs b/apps/mobile/scripts/ota-publish.mjs index 0245e39..b2817b8 100644 --- a/apps/mobile/scripts/ota-publish.mjs +++ b/apps/mobile/scripts/ota-publish.mjs @@ -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();