From ecc31a13a2fae616cbfb8a32ef52c2cba6808fe1 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Mon, 31 Aug 2026 16:03:04 +0100 Subject: [PATCH] feat(release): enforce manifest and Info.plist release invariants Extend release-preflight to reject an Android manifest missing the READ_MEDIA_IMAGES/READ_MEDIA_VIDEO tools:node="remove" attributes and an iOS Info.plist that regained dev-launcher-only keys, both regressions introduced silently by expo prebuild during the SDK 54 to 56 upgrade. Co-Authored-By: Claude Fable 5 --- apps/mobile/AGENTS.md | 2 ++ apps/mobile/scripts/release-preflight.mjs | 12 +++++++ .../mobile/scripts/release-preflight.test.mjs | 32 +++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 0d0a1f9..994f8d1 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -13,3 +13,5 @@ native iOS/Android targets. - Add stable accessibility labels or `testID` values for critical interactions. - Verify focused Jest tests, typecheck, lint, then native/Expo behavior as risk requires. UI work needs observable simulator evidence. +- Before and after any `expo prebuild`, follow the "After native regeneration" + checklist in `docs/releasing.md`. diff --git a/apps/mobile/scripts/release-preflight.mjs b/apps/mobile/scripts/release-preflight.mjs index 67e6c61..970b143 100644 --- a/apps/mobile/scripts/release-preflight.mjs +++ b/apps/mobile/scripts/release-preflight.mjs @@ -39,6 +39,7 @@ export function validateReleaseSources({ root = repoRoot, isTracked } = {}) { 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 androidManifest = file('apps/mobile/android/app/src/main/AndroidManifest.xml'); 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'); @@ -70,6 +71,17 @@ export function validateReleaseSources({ root = repoRoot, isTracked } = {}) { fail(`iOS app, widget, and effective widget Xcode build numbers disagree: app ${appBuild}; widget plist ${widgetBuild}; widget settings ${widgetBuildSettings.join(', ')}`); } + for (const permission of ['READ_MEDIA_IMAGES', 'READ_MEDIA_VIDEO']) { + const removed = new RegExp(`]*android:name="android\\.permission\\.${permission}"[^>]*tools:node="remove"`).test(androidManifest); + if (!removed) fail(`AndroidManifest.xml must keep tools:node="remove" on ${permission} (expo prebuild regresses this)`); + } + + const forbiddenPlistKeys = ['NSBonjourServices', 'NSLocalNetworkUsageDescription', 'RCTMetroPort']; + const foundDevLauncherKeys = forbiddenPlistKeys.filter((key) => iosInfo.includes(key)); + if (foundDevLauncherKeys.length) { + fail(`ios/Sauci/Info.plist must not contain dev-launcher-only keys reintroduced by expo prebuild: ${foundDevLauncherKeys.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'; diff --git a/apps/mobile/scripts/release-preflight.test.mjs b/apps/mobile/scripts/release-preflight.test.mjs index 192c7b4..683014b 100644 --- a/apps/mobile/scripts/release-preflight.test.mjs +++ b/apps/mobile/scripts/release-preflight.test.mjs @@ -67,6 +67,38 @@ test('rejects an OTA certificate without its root archive allowlist', () => { } }); +test('rejects an Android manifest that lost the media permission removal (expo prebuild regression)', () => { + const root = fixture(); + try { + const manifest = 'apps/mobile/android/app/src/main/AndroidManifest.xml'; + replace( + root, + manifest, + '', + '', + ); + assert.throws(() => validate(root), /READ_MEDIA_IMAGES \(expo prebuild regresses this\)/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('rejects an iOS Info.plist that regained dev-launcher-only keys (expo prebuild regression)', () => { + const root = fixture(); + try { + const infoPlist = 'apps/mobile/ios/Sauci/Info.plist'; + replace( + root, + infoPlist, + '\n', + 'NSBonjourServices\n_expo._tcp\n\n', + ); + assert.throws(() => validate(root), /dev-launcher-only keys reintroduced by expo prebuild: NSBonjourServices/); + } 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');