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 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Fable 5 committed 2026-08-31 16:03:04 +01:00
1 parent 7a76936829
commit ecc31a13a2
3 files changed
+46

No files matched your search

+12
View File
@@ -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(`<uses-permission[^>]*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';
@@ -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,
'<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>',
'<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>',
);
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,
'</dict>\n</plist>',
'<key>NSBonjourServices</key>\n<array><string>_expo._tcp</string></array>\n</dict>\n</plist>',
);
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');