From 07492a092d7eba00b4b498337a7c6bb2272c72ee Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Fri, 14 Aug 2026 18:54:33 -0400 Subject: [PATCH 1/6] fix(expo): don't force iOS deployment target down to 16.4 (#1066) The config plugin hardcoded 16.4 in both withBuildProperties and the Podfile post_install patch, overriding apps that need a higher target (e.g. react-native-executorch requires 17.0). Read the app's own expo-build-properties deploymentTarget and only raise it when it's below our 16.4 minimum. --- .../src/expo-plugin/withXCode.ts | 37 +++++++++++++++++-- .../test/withXCode.test.ts | 32 ++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 packages/react-native-quick-crypto/test/withXCode.test.ts diff --git a/packages/react-native-quick-crypto/src/expo-plugin/withXCode.ts b/packages/react-native-quick-crypto/src/expo-plugin/withXCode.ts index f0c167fc..3ac2738a 100644 --- a/packages/react-native-quick-crypto/src/expo-plugin/withXCode.ts +++ b/packages/react-native-quick-crypto/src/expo-plugin/withXCode.ts @@ -1,3 +1,4 @@ +import type { ExpoConfig } from '@expo/config-types'; import type { ConfigPlugin } from 'expo/config-plugins'; import type { ConfigProps } from './@types'; import { withBuildProperties } from 'expo-build-properties'; @@ -5,15 +6,45 @@ import { withDangerousMod } from 'expo/config-plugins'; import fs from 'fs'; import path from 'path'; +const MIN_IOS_DEPLOYMENT_TARGET = '16.4'; + +function isAtLeast(version: string, minimum: string): boolean { + const a = version.split('.').map(Number); + const b = minimum.split('.').map(Number); + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const diff = (a[i] ?? 0) - (b[i] ?? 0); + if (diff !== 0) return diff > 0; + } + return true; +} + +/** + * Deployment target already requested by the app via expo-build-properties, + * so we only ever raise it - never lower it. + */ +export function resolveDeploymentTarget( + plugins: ExpoConfig['plugins'], +): string { + const configured = plugins?.find( + (p): p is [string, { ios?: { deploymentTarget?: string } }] => + Array.isArray(p) && String(p[0]).includes('expo-build-properties'), + )?.[1]?.ios?.deploymentTarget; + + return configured && isAtLeast(configured, MIN_IOS_DEPLOYMENT_TARGET) + ? configured + : MIN_IOS_DEPLOYMENT_TARGET; +} + /** * Workaround for some jank XCode releases that break React Native native modules * * see: https://github.com/mrousavy/nitro/issues/422#issuecomment-2545988256 */ export const withXCode: ConfigPlugin = config => { + const deploymentTarget = resolveDeploymentTarget(config.plugins); // Use expo-build-properties to bump iOS deployment target - config = withBuildProperties(config, { ios: { deploymentTarget: '16.4' } }); - // Patch the generated Podfile fallback to ensure platform is always 16.4 + config = withBuildProperties(config, { ios: { deploymentTarget } }); + // Patch the generated Podfile fallback to ensure platform is always set config = withDangerousMod(config, [ 'ios', modConfig => { @@ -40,7 +71,7 @@ export const withXCode: ConfigPlugin = config => { # https://github.com/mrousavy/nitro/issues/422#issuecomment-2545988256 installer.pods_project.targets.each do |target| target.build_configurations.each do |config| - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.4' + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '${deploymentTarget}' end end $2`, diff --git a/packages/react-native-quick-crypto/test/withXCode.test.ts b/packages/react-native-quick-crypto/test/withXCode.test.ts new file mode 100644 index 00000000..c7e0717e --- /dev/null +++ b/packages/react-native-quick-crypto/test/withXCode.test.ts @@ -0,0 +1,32 @@ +import { resolveDeploymentTarget } from '../src/expo-plugin/withXCode'; + +test('resolveDeploymentTarget keeps a higher app target', () => { + const target = resolveDeploymentTarget([ + ['expo-build-properties', { ios: { deploymentTarget: '17.0' } }], + ]); + expect(target).toBe('17.0'); +}); + +test('resolveDeploymentTarget raises a lower or missing app target', () => { + expect( + resolveDeploymentTarget([ + ['expo-build-properties', { ios: { deploymentTarget: '15.1' } }], + ]), + ).toBe('16.4'); + expect(resolveDeploymentTarget([['expo-build-properties', {}]])).toBe('16.4'); + expect(resolveDeploymentTarget(['some-other-plugin'])).toBe('16.4'); + expect(resolveDeploymentTarget(undefined)).toBe('16.4'); +}); + +test('resolveDeploymentTarget compares numerically, not lexically', () => { + expect( + resolveDeploymentTarget([ + ['expo-build-properties', { ios: { deploymentTarget: '16.10' } }], + ]), + ).toBe('16.10'); + expect( + resolveDeploymentTarget([ + ['expo-build-properties', { ios: { deploymentTarget: '9.0' } }], + ]), + ).toBe('16.4'); +}); From 8ab629e62fe4c11f2930fbaa23b4a6e7dee237da Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Fri, 14 Aug 2026 19:16:07 -0400 Subject: [PATCH 2/6] fix(expo): floor the iOS deployment target instead of pinning it to 16.4 Reads the target at mod time (withPodfileProperties + withXcodeProject) rather than scanning static plugins config, so 16.4 is a floor everywhere and an app that raises the target by any means keeps it. The Podfile post_install fallback now compares with Gem::Version rather than to_f, which treated '16.10' as 16.1 and silently downgraded it. Drops the expo-build-properties dependency - apps no longer need it installed for this plugin to work. Tests cover the generated Ruby against a real interpreter, and CI now runs the package's jest suite. --- .github/workflows/validate-js.yml | 7 + bun.lock | 11 -- .../react-native-quick-crypto/package.json | 7 +- .../src/expo-plugin/withXCode.ts | 154 ++++++++++++------ .../test/withXCode.test.ts | 143 ++++++++++++---- 5 files changed, 223 insertions(+), 99 deletions(-) diff --git a/.github/workflows/validate-js.yml b/.github/workflows/validate-js.yml index 77860f50..a8e9dc49 100644 --- a/.github/workflows/validate-js.yml +++ b/.github/workflows/validate-js.yml @@ -8,6 +8,7 @@ on: - '.github/workflows/validate-js.yml' - 'bun.lock' - 'packages/react-native-quick-crypto/src/**' + - 'packages/react-native-quick-crypto/test/**' - 'packages/react-native-quick-crypto/*.json' - 'packages/react-native-quick-crypto/*.*s' - 'packages/react-native-quick-crypto/bun.lock' @@ -21,6 +22,7 @@ on: - '.github/workflows/validate-js.yml' - 'bun.lock' - 'packages/react-native-quick-crypto/src/**' + - 'packages/react-native-quick-crypto/test/**' - 'packages/react-native-quick-crypto/*.json' - 'packages/react-native-quick-crypto/*.*s' - 'packages/react-native-quick-crypto/bun.lock' @@ -70,6 +72,11 @@ jobs: cd packages/react-native-quick-crypto bun circular + - name: Run tests + run: | + cd packages/react-native-quick-crypto + bun run test + audit_runtime_deps: name: Audit runtime deps (bun audit) runs-on: ubuntu-latest diff --git a/bun.lock b/bun.lock index 90f877d0..2f44d270 100644 --- a/bun.lock +++ b/bun.lock @@ -103,7 +103,6 @@ "del-cli": "7.0.0", "dpdm": "^4.0.1", "expo": "^54.0.25", - "expo-build-properties": "^1.0.0", "jest": "29.7.0", "nitrogen": "0.33.2", "react-native-builder-bob": "0.40.15", @@ -111,7 +110,6 @@ }, "peerDependencies": { "expo": ">=48.0.0", - "expo-build-properties": "*", "react": "*", "react-native": "*", "react-native-nitro-modules": ">=0.31.2", @@ -119,7 +117,6 @@ }, "optionalPeers": [ "expo", - "expo-build-properties", ], }, }, @@ -1293,8 +1290,6 @@ "expo-asset": ["expo-asset@12.0.12", "", { "dependencies": { "@expo/image-utils": "^0.8.8", "expo-constants": "~18.0.12" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ=="], - "expo-build-properties": ["expo-build-properties@1.0.10", "", { "dependencies": { "ajv": "^8.11.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-mFCZbrbrv0AP5RB151tAoRzwRJelqM7bCJzCkxpu+owOyH+p/rFC/q7H5q8B9EpVWj8etaIuszR+gKwohpmu1Q=="], - "expo-constants": ["expo-constants@18.0.13", "", { "dependencies": { "@expo/config": "~12.0.13", "@expo/env": "~2.0.8" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ=="], "expo-file-system": ["expo-file-system@19.0.21", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg=="], @@ -1329,8 +1324,6 @@ "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], - "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], - "fast-wrap-ansi": ["fast-wrap-ansi@0.2.0", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w=="], "fast-xml-parser": ["fast-xml-parser@4.5.3", "", { "dependencies": { "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig=="], @@ -2731,8 +2724,6 @@ "eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "expo-build-properties/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], - "expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -3099,8 +3090,6 @@ "eslint-plugin-jest/@typescript-eslint/utils/eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="], - "expo-build-properties/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "hash-base/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], diff --git a/packages/react-native-quick-crypto/package.json b/packages/react-native-quick-crypto/package.json index c7a1e84f..98544bc2 100644 --- a/packages/react-native-quick-crypto/package.json +++ b/packages/react-native-quick-crypto/package.json @@ -121,7 +121,6 @@ "del-cli": "7.0.0", "dpdm": "^4.0.1", "expo": "^54.0.25", - "expo-build-properties": "^1.0.0", "jest": "29.7.0", "nitrogen": "0.33.2", "react-native-builder-bob": "0.40.15", @@ -132,15 +131,11 @@ "react-native": "*", "react-native-nitro-modules": ">=0.31.2", "react-native-quick-base64": ">=3.0.0", - "expo": ">=48.0.0", - "expo-build-properties": "*" + "expo": ">=48.0.0" }, "peerDependenciesMeta": { "expo": { "optional": true - }, - "expo-build-properties": { - "optional": true } }, "release-it": { diff --git a/packages/react-native-quick-crypto/src/expo-plugin/withXCode.ts b/packages/react-native-quick-crypto/src/expo-plugin/withXCode.ts index 3ac2738a..71e90b81 100644 --- a/packages/react-native-quick-crypto/src/expo-plugin/withXCode.ts +++ b/packages/react-native-quick-crypto/src/expo-plugin/withXCode.ts @@ -1,50 +1,120 @@ -import type { ExpoConfig } from '@expo/config-types'; import type { ConfigPlugin } from 'expo/config-plugins'; import type { ConfigProps } from './@types'; -import { withBuildProperties } from 'expo-build-properties'; -import { withDangerousMod } from 'expo/config-plugins'; +import { + IOSConfig, + withDangerousMod, + withPodfileProperties, + withXcodeProject, +} from 'expo/config-plugins'; import fs from 'fs'; import path from 'path'; const MIN_IOS_DEPLOYMENT_TARGET = '16.4'; +const MIN_PARTS = MIN_IOS_DEPLOYMENT_TARGET.split('.').map(Number); -function isAtLeast(version: string, minimum: string): boolean { - const a = version.split('.').map(Number); - const b = minimum.split('.').map(Number); - for (let i = 0; i < Math.max(a.length, b.length); i++) { - const diff = (a[i] ?? 0) - (b[i] ?? 0); - if (diff !== 0) return diff > 0; +/** + * True when `version` is absent or below our minimum, i.e. we should raise it. + * Compared component-wise: '16.10' and '16.4.1' are both above '16.4'. + */ +export function needsRaising(version: string | undefined): boolean { + if (!version) return true; + + const parts = version.split('.').map(Number); + if (!parts.every(Number.isFinite)) { + console.warn( + `[react-native-quick-crypto] leaving unparseable iOS deployment target "${version}" alone`, + ); + return false; } - return true; + + for (let i = 0; i < Math.max(parts.length, MIN_PARTS.length); i++) { + const diff = (parts[i] ?? 0) - (MIN_PARTS[i] ?? 0); + if (diff !== 0) return diff < 0; + } + return false; } +const POST_INSTALL_PATCH = ` # react-native-quick-crypto: floor the pods' deployment target, never lower it + # https://github.com/mrousavy/nitro/issues/422#issuecomment-2545988256 + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + current = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_s + if !Gem::Version.correct?(current) || + Gem::Version.new(current) < Gem::Version.new('${MIN_IOS_DEPLOYMENT_TARGET}') + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '${MIN_IOS_DEPLOYMENT_TARGET}' + end + end + end`; + /** - * Deployment target already requested by the app via expo-build-properties, - * so we only ever raise it - never lower it. + * Fallback for Podfiles whose post_install doesn't already pin a deployment + * target. No-op when one is already pinned - that's the app's call, not ours. */ -export function resolveDeploymentTarget( - plugins: ExpoConfig['plugins'], -): string { - const configured = plugins?.find( - (p): p is [string, { ios?: { deploymentTarget?: string } }] => - Array.isArray(p) && String(p[0]).includes('expo-build-properties'), - )?.[1]?.ios?.deploymentTarget; +export function patchPodfile(contents: string): string { + const alreadyPinned = + /\.build_settings\s*\[\s*['"]IPHONEOS_DEPLOYMENT_TARGET['"]\s*\]\s*=/.test( + contents, + ); + if (alreadyPinned) return contents; - return configured && isAtLeast(configured, MIN_IOS_DEPLOYMENT_TARGET) - ? configured - : MIN_IOS_DEPLOYMENT_TARGET; + const patched = contents.replace( + /(post_install\s+do\s+\|installer\|[\s\S]*?)(\r?\n\s\send\s*)$/m, + `$1\n\n${POST_INSTALL_PATCH}\n$2`, + ); + if (patched === contents) { + console.warn( + '[react-native-quick-crypto] no post_install block found in the Podfile; skipping the iOS deployment target floor', + ); + } + return patched; } /** * Workaround for some jank XCode releases that break React Native native modules * * see: https://github.com/mrousavy/nitro/issues/422#issuecomment-2545988256 + * + * Raises the iOS deployment target to our minimum wherever it is lower. Reads + * the value at mod time rather than from static `plugins` config, so it floors + * apps that set a higher target by any means and never lowers one. (#1066) */ export const withXCode: ConfigPlugin = config => { - const deploymentTarget = resolveDeploymentTarget(config.plugins); - // Use expo-build-properties to bump iOS deployment target - config = withBuildProperties(config, { ios: { deploymentTarget } }); - // Patch the generated Podfile fallback to ensure platform is always set + // what the generated Podfile reads for `platform :ios` + config = withPodfileProperties(config, podfileConfig => { + if (needsRaising(podfileConfig.modResults['ios.deploymentTarget'])) { + podfileConfig.modResults['ios.deploymentTarget'] = + MIN_IOS_DEPLOYMENT_TARGET; + } + return podfileConfig; + }); + + // the app target in the generated Xcode project + config = withXcodeProject(config, xcodeConfig => { + const { Target, XcodeUtils } = IOSConfig; + const listIds = Target.getNativeTargets(xcodeConfig.modResults) + .filter(([, target]) => + Target.isTargetOfType(target, Target.TargetType.APPLICATION), + ) + .map(([, target]) => target.buildConfigurationList); + + for (const listId of listIds) { + const configurations = XcodeUtils.getBuildConfigurationsForListId( + xcodeConfig.modResults, + listId, + ); + for (const [, { buildSettings }] of configurations) { + // only floor an explicit target - writing one that was inherited from + // the project level could itself be a downgrade + const current = buildSettings?.IPHONEOS_DEPLOYMENT_TARGET; + if (current && needsRaising(String(current))) { + buildSettings.IPHONEOS_DEPLOYMENT_TARGET = MIN_IOS_DEPLOYMENT_TARGET; + } + } + } + return xcodeConfig; + }); + + // pods that don't inherit the platform from the Podfile config = withDangerousMod(config, [ 'ios', modConfig => { @@ -52,35 +122,13 @@ export const withXCode: ConfigPlugin = config => { modConfig.modRequest.platformProjectRoot, 'Podfile', ); - let contents = fs.readFileSync(podfilePath, 'utf-8'); - - // Check if the IPHONEOS_DEPLOYMENT_TARGET setting is already present - // We search for the key being assigned, e.g., config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = - const deploymentTargetSettingExists = - /\.build_settings\s*\[\s*['"]IPHONEOS_DEPLOYMENT_TARGET['"]\s*\]\s*=/.test( - contents, - ); - - if (!deploymentTargetSettingExists) { - // IPHONEOS_DEPLOYMENT_TARGET setting not found, proceed to add it. - contents = contents.replace( - /(post_install\s+do\s+\|installer\|[\s\S]*?)(\r?\n\s\send\s*)$/m, - `$1 - - # Expo Build Properties: force deployment target - # https://github.com/mrousavy/nitro/issues/422#issuecomment-2545988256 - installer.pods_project.targets.each do |target| - target.build_configurations.each do |config| - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '${deploymentTarget}' - end - end -$2`, - ); - } - - fs.writeFileSync(podfilePath, contents); + fs.writeFileSync( + podfilePath, + patchPodfile(fs.readFileSync(podfilePath, 'utf-8')), + ); return modConfig; }, ]); + return config; }; diff --git a/packages/react-native-quick-crypto/test/withXCode.test.ts b/packages/react-native-quick-crypto/test/withXCode.test.ts index c7e0717e..581b5e9b 100644 --- a/packages/react-native-quick-crypto/test/withXCode.test.ts +++ b/packages/react-native-quick-crypto/test/withXCode.test.ts @@ -1,32 +1,117 @@ -import { resolveDeploymentTarget } from '../src/expo-plugin/withXCode'; +import { execFileSync } from 'child_process'; +import { needsRaising, patchPodfile } from '../src/expo-plugin/withXCode'; -test('resolveDeploymentTarget keeps a higher app target', () => { - const target = resolveDeploymentTarget([ - ['expo-build-properties', { ios: { deploymentTarget: '17.0' } }], +const PODFILE = `require File.join(File.dirname(\`node --print "require.resolve('expo/package.json')"\`), "scripts/autolinking") + +platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1' + +target 'example' do + use_expo_modules! + + post_install do |installer| + react_native_post_install(installer, config[:reactNativePath]) + end +end +`; + +test('needsRaising floors anything below the minimum', () => { + expect(needsRaising(undefined)).toBe(true); + expect(needsRaising('')).toBe(true); + expect(needsRaising('15.1')).toBe(true); + expect(needsRaising('9.0')).toBe(true); +}); + +test('needsRaising leaves the minimum and anything above it alone', () => { + expect(needsRaising('16.4')).toBe(false); + expect(needsRaising('17.0')).toBe(false); + expect(needsRaising('26.0')).toBe(false); +}); + +test('needsRaising compares component-wise, not lexically or as a float', () => { + expect(needsRaising('16.10')).toBe(false); + expect(needsRaising('16.4.1')).toBe(false); + expect(needsRaising('16.3.9')).toBe(true); +}); + +test('needsRaising leaves an unparseable target alone', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + expect(needsRaising('17.0-beta')).toBe(false); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); +}); + +test('patchPodfile injects the floor into post_install', () => { + const patched = patchPodfile(PODFILE); + expect(patched).toMatch(/Gem::Version\.new\('16\.4'\)/); + expect(patched).toMatch(/react_native_post_install/); +}); + +test('patchPodfile leaves a Podfile that already pins a target alone', () => { + const pinned = PODFILE.replace( + 'react_native_post_install(installer, config[:reactNativePath])', + "config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '17.0'", + ); + expect(patchPodfile(pinned)).toBe(pinned); +}); + +test('patchPodfile warns instead of silently no-oping when post_install is missing', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const noPostInstall = "platform :ios, '15.1'\n"; + expect(patchPodfile(noPostInstall)).toBe(noPostInstall); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); +}); + +// The injected block is Ruby generated from a template string - nothing else in +// the toolchain parses it, so check it against a real interpreter when present. +const ruby = (() => { + try { + execFileSync('ruby', ['-e', '']); + return test; + } catch { + return test.skip; + } +})(); + +ruby('the injected Ruby is syntactically valid', () => { + execFileSync('ruby', ['-c', '-'], { input: patchPodfile(PODFILE) }); +}); + +ruby('the injected Ruby raises low targets and preserves high ones', () => { + const block = patchPodfile(PODFILE) + .split('installer.pods_project.targets.each do |target|')[1] + ?.split('\n end\n end')[0] + ?.split('target.build_configurations.each do |config|')[1]; + + const result = execFileSync( + 'ruby', + [ + '-e', + ` + require 'json' + settings = JSON.parse(ARGV[0]) + puts(settings.map { |s| + config = Struct.new(:build_settings).new(s) + ${block} + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] + }.to_json) + `, + JSON.stringify([ + { IPHONEOS_DEPLOYMENT_TARGET: '15.1' }, + { IPHONEOS_DEPLOYMENT_TARGET: '16.10' }, + { IPHONEOS_DEPLOYMENT_TARGET: '17.0.1' }, + { IPHONEOS_DEPLOYMENT_TARGET: 'garbage' }, + {}, + ]), + ], + { encoding: 'utf-8' }, + ); + + expect(JSON.parse(result)).toEqual([ + '16.4', // raised + '16.10', // preserved - float compare would have downgraded this + '17.0.1', // preserved - unquoted interpolation would have been a syntax error + '16.4', // unparseable, floored + '16.4', // absent, floored ]); - expect(target).toBe('17.0'); -}); - -test('resolveDeploymentTarget raises a lower or missing app target', () => { - expect( - resolveDeploymentTarget([ - ['expo-build-properties', { ios: { deploymentTarget: '15.1' } }], - ]), - ).toBe('16.4'); - expect(resolveDeploymentTarget([['expo-build-properties', {}]])).toBe('16.4'); - expect(resolveDeploymentTarget(['some-other-plugin'])).toBe('16.4'); - expect(resolveDeploymentTarget(undefined)).toBe('16.4'); -}); - -test('resolveDeploymentTarget compares numerically, not lexically', () => { - expect( - resolveDeploymentTarget([ - ['expo-build-properties', { ios: { deploymentTarget: '16.10' } }], - ]), - ).toBe('16.10'); - expect( - resolveDeploymentTarget([ - ['expo-build-properties', { ios: { deploymentTarget: '9.0' } }], - ]), - ).toBe('16.4'); }); From 9b5964351937b3d588ebe6ae494f08dfdc6ab34f Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Fri, 14 Aug 2026 19:20:34 -0400 Subject: [PATCH 3/6] chore(agents): spawn an independent reviewer for /review instead of stopping /review previously refused to run when the session had prior history, telling the user to start a fresh session by hand. Now it spawns a subagent with a clean context, picking the type from what the diff touches (crypto/cpp/typescript specialist, or general-purpose). Also switches the review base to origin/main, since a stale local main silently drags already-merged commits into scope, and broadens the verification step to the toolchains a given diff actually touches. Mirrors the equivalent commands in the spicy and trading repos. --- .agents/commands/review.md | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/.agents/commands/review.md b/.agents/commands/review.md index 828a8601..e5943c78 100644 --- a/.agents/commands/review.md +++ b/.agents/commands/review.md @@ -4,20 +4,25 @@ Review all commits on the current branch since diverging from main. ## Prerequisites -**IMPORTANT**: Before starting the review, check if this is a fresh context/session: +**IMPORTANT**: A reviewer should not be the same "person" who wrote the code. Before starting, check if this is a fresh context/session: -- If there is prior conversation history in this session (e.g., you helped write the code being reviewed), STOP immediately -- Inform the user: "Code reviews should be done in a fresh context to avoid bias. Please start a new agent session and run /review there." -- A reviewer should not be the same "person" who wrote the code +- **If there is prior conversation history in this session** (e.g., you helped write the code being reviewed), do NOT review it yourself — your context is biased. Instead, spawn a fresh reviewer: + - Launch a subagent (synchronously — `run_in_background: false`) with a prompt telling it to perform the full review defined in the **Instructions** section below on the current branch, and to return its findings as: a summary, positives, and a severity-ranked list of issues (each with `file:line`, description, and a proposed action). Paste the Instructions criteria into the prompt so the subagent doesn't need to re-read this file. + - Pick the subagent type by what the diff actually touches: `crypto-specialist` for crypto/algorithm changes, `cpp-specialist` for `cpp/`, `typescript-specialist` for TS-only changes, `general-purpose` otherwise. Spawn more than one in parallel when the diff spans domains. + - **Scope the prompt to THIS branch's actual changes, not the generic template.** Before spawning, run `git diff --stat origin/main..HEAD` and put the concrete context into the prompt: what the branch does, which subsystem it touches (this repo spans C++/OpenSSL under `packages/react-native-quick-crypto/cpp`, the TypeScript package under `src/`, the Expo config plugin, the `example/` RN app and its test suites, `docs/`, and CI workflows — name the ones actually changed), and the list of changed files. Tell the subagent to open and review **every** changed file, including non-C++ ones (`.ts`, `.tsx`, podspec, Gradle, `.github/workflows`, `.agents/`), and to validate **each** changed toolchain rather than assuming one check covers the diff. + - The subagent starts with a clean context and did NOT write the code, so its review is unbiased. + - When it returns, relay its review to the user verbatim, then run the **Follow-up** fix-plan step yourself (the subagent can't interact with the user). +- **If this is a fresh context** (no prior history — you did not write this code), perform the review directly. ## Instructions -When activated (in a fresh session), perform a full code review of the commits since branching from main: +When activated, perform a full code review of the commits since branching from main: -1. **Get the commits**: Run `git log main..HEAD --oneline` to see all commits on this branch -2. **Get the full diff**: Run `git diff main..HEAD` to see all changes -3. **For each file changed**, read enough context to understand the changes -4. **Review for**: +1. **Sync the base**: Run `git fetch origin` first. Local `main` is often stale (a PR merged upstream but not pulled locally), which silently drags already-merged commits into the review scope and balloons the diff. Use `origin/main` as the base for everything below. +2. **Get the commits**: Run `git log origin/main..HEAD --oneline` to see all commits on this branch +3. **Get the full diff**: Run `git diff origin/main..HEAD` to see all changes +4. **For each file changed**, read enough context to understand the changes +5. **Review for**: - Correctness and logic errors - Consistency with existing patterns in the codebase - TypeScript best practices @@ -26,13 +31,13 @@ When activated (in a fresh session), perform a full code review of the commits s - Potential bugs or edge cases - Missing error handling - Code clarity and maintainability -5. **Provide a structured review** with: +6. **Provide a structured review** with: - Summary of what the branch does - Positives (what's done well) - Issues & suggestions (ranked by severity) - Recommended actions (if any) -Run `bun tsc` to verify the code compiles. +Verify the toolchains the diff actually touches: `bun tsc` for TypeScript, `bun test` (in `packages/react-native-quick-crypto`) if node-side tests changed, `clang-format --dry-run --Werror` for C++. C++ runtime behavior and example-app test suites can only be validated by the user running `bun ios` / `bun android` — flag that rather than claiming it passes. ## Follow-up @@ -46,4 +51,8 @@ After presenting the review, present a **fix plan table** for the user to approv - **Skip**: Not worth changing (explain why) - **Ask**: Ambiguous, needs user input on approach -**Wait for the user to approve the plan** (they may want to skip or modify items). Then apply only the approved fixes. Run `bun tsc` after all fixes are applied to verify everything is clean. +**Wait for the user to approve the plan** (they may want to skip or modify items). They may reply with "approve" / "approve all" to accept everything, override individual rows ("skip #3", "fix #5 differently"), or ask clarifying questions. + +Once approved, apply only the approved fixes, then re-run the same toolchain checks to verify everything is clean. + +Then commit the approved fixes (via `/commit`) — don't leave review changes sitting in the working tree. From 751242fe55b5008066298f43a084b19f902a167d Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Fri, 14 Aug 2026 19:23:19 -0400 Subject: [PATCH 4/6] fix(test): declare @babel/runtime so jest resolves it outside a warm tree The jest suites only ever ran locally, where @babel/runtime hoists to the workspace root from example/. On a clean install it isn't resolvable from packages/react-native-quick-crypto, so both suites failed to run as soon as CI started invoking them. --- bun.lock | 1 + packages/react-native-quick-crypto/package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/bun.lock b/bun.lock index 2f44d270..45f8fbc1 100644 --- a/bun.lock +++ b/bun.lock @@ -96,6 +96,7 @@ "util": "0.12.5", }, "devDependencies": { + "@babel/runtime": "7.28.4", "@types/jest": "29.5.11", "@types/node": "24.3.0", "@types/react": "18.3.3", diff --git a/packages/react-native-quick-crypto/package.json b/packages/react-native-quick-crypto/package.json index 98544bc2..3a6d449f 100644 --- a/packages/react-native-quick-crypto/package.json +++ b/packages/react-native-quick-crypto/package.json @@ -114,6 +114,7 @@ "util": "0.12.5" }, "devDependencies": { + "@babel/runtime": "7.28.4", "@types/jest": "29.5.11", "@types/node": "24.3.0", "@types/react": "18.3.3", From c95b6c40f78eda3f859a694658370c5576958887 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Fri, 14 Aug 2026 19:23:28 -0400 Subject: [PATCH 5/6] fix(agents): use a three-dot diff for the /review base git diff A..B compares endpoints, not the merge-base, so anything merged into origin/main since the branch point rendered as an inverse diff - the exact scope explosion step 1 was added to prevent. Observed live: 8 files instead of 2. Note that git log takes two dots and git diff takes three, so the fix documents that rather than looking like a typo to the next editor. Also puts uncommitted work in scope and disambiguates bun run test. --- .agents/commands/review.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.agents/commands/review.md b/.agents/commands/review.md index e5943c78..c2c23a5f 100644 --- a/.agents/commands/review.md +++ b/.agents/commands/review.md @@ -9,7 +9,7 @@ Review all commits on the current branch since diverging from main. - **If there is prior conversation history in this session** (e.g., you helped write the code being reviewed), do NOT review it yourself — your context is biased. Instead, spawn a fresh reviewer: - Launch a subagent (synchronously — `run_in_background: false`) with a prompt telling it to perform the full review defined in the **Instructions** section below on the current branch, and to return its findings as: a summary, positives, and a severity-ranked list of issues (each with `file:line`, description, and a proposed action). Paste the Instructions criteria into the prompt so the subagent doesn't need to re-read this file. - Pick the subagent type by what the diff actually touches: `crypto-specialist` for crypto/algorithm changes, `cpp-specialist` for `cpp/`, `typescript-specialist` for TS-only changes, `general-purpose` otherwise. Spawn more than one in parallel when the diff spans domains. - - **Scope the prompt to THIS branch's actual changes, not the generic template.** Before spawning, run `git diff --stat origin/main..HEAD` and put the concrete context into the prompt: what the branch does, which subsystem it touches (this repo spans C++/OpenSSL under `packages/react-native-quick-crypto/cpp`, the TypeScript package under `src/`, the Expo config plugin, the `example/` RN app and its test suites, `docs/`, and CI workflows — name the ones actually changed), and the list of changed files. Tell the subagent to open and review **every** changed file, including non-C++ ones (`.ts`, `.tsx`, podspec, Gradle, `.github/workflows`, `.agents/`), and to validate **each** changed toolchain rather than assuming one check covers the diff. + - **Scope the prompt to THIS branch's actual changes, not the generic template.** Before spawning, run `git diff --stat origin/main...HEAD` (three dots — see step 3) and put the concrete context into the prompt: what the branch does, which subsystem it touches (this repo spans C++/OpenSSL under `packages/react-native-quick-crypto/cpp`, the TypeScript package under `src/`, the Expo config plugin, the `example/` RN app and its test suites, `docs/`, and CI workflows — name the ones actually changed), and the list of changed files. Tell the subagent to open and review **every** changed file, including non-C++ ones (`.ts`, `.tsx`, podspec, Gradle, `.github/workflows`, `.agents/`), and to validate **each** changed toolchain rather than assuming one check covers the diff. - The subagent starts with a clean context and did NOT write the code, so its review is unbiased. - When it returns, relay its review to the user verbatim, then run the **Follow-up** fix-plan step yourself (the subagent can't interact with the user). - **If this is a fresh context** (no prior history — you did not write this code), perform the review directly. @@ -20,9 +20,10 @@ When activated, perform a full code review of the commits since branching from m 1. **Sync the base**: Run `git fetch origin` first. Local `main` is often stale (a PR merged upstream but not pulled locally), which silently drags already-merged commits into the review scope and balloons the diff. Use `origin/main` as the base for everything below. 2. **Get the commits**: Run `git log origin/main..HEAD --oneline` to see all commits on this branch -3. **Get the full diff**: Run `git diff origin/main..HEAD` to see all changes -4. **For each file changed**, read enough context to understand the changes -5. **Review for**: +3. **Get the full diff**: Run `git diff origin/main...HEAD` to see all changes. **Three dots.** For `diff`, two dots compares the two endpoints, so anything merged into `origin/main` since the branch point shows up as an inverse diff — as if this branch deleted it. Three dots diffs against the merge-base, which is what you want. Note that step 2's `git log` takes **two** dots: the same syntax means different things for `log` and `diff`. +4. **Also check the working tree**: run `git status --short` and `git diff`. Uncommitted changes are in scope — review them alongside the commits, and say which findings apply to uncommitted work. +5. **For each file changed**, read enough context to understand the changes +6. **Review for**: - Correctness and logic errors - Consistency with existing patterns in the codebase - TypeScript best practices @@ -31,13 +32,13 @@ When activated, perform a full code review of the commits since branching from m - Potential bugs or edge cases - Missing error handling - Code clarity and maintainability -6. **Provide a structured review** with: +7. **Provide a structured review** with: - Summary of what the branch does - Positives (what's done well) - Issues & suggestions (ranked by severity) - Recommended actions (if any) -Verify the toolchains the diff actually touches: `bun tsc` for TypeScript, `bun test` (in `packages/react-native-quick-crypto`) if node-side tests changed, `clang-format --dry-run --Werror` for C++. C++ runtime behavior and example-app test suites can only be validated by the user running `bun ios` / `bun android` — flag that rather than claiming it passes. +Verify the toolchains the diff actually touches: `bun tsc` for TypeScript, `bun run test` (in `packages/react-native-quick-crypto` — `run` matters, bare `bun test` is Bun's own runner, not the package's jest) if node-side code changed, `clang-format --dry-run --Werror` for C++. C++ runtime behavior and example-app test suites can only be validated by the user running `bun ios` / `bun android` — flag that rather than claiming it passes. ## Follow-up @@ -55,4 +56,4 @@ After presenting the review, present a **fix plan table** for the user to approv Once approved, apply only the approved fixes, then re-run the same toolchain checks to verify everything is clean. -Then commit the approved fixes (via `/commit`) — don't leave review changes sitting in the working tree. +Then commit the approved fixes (via `/commit`) — don't leave review changes sitting in the working tree. Commit only; pushing and opening PRs still need explicit permission. From baf5c250148e2e8f5853d50ebd77348bb1cd011d Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Fri, 14 Aug 2026 19:29:38 -0400 Subject: [PATCH 6/6] fix(ci): ignore two metro-only image-size advisories in the runtime audit Both are reached through react-native's own tree, which enters the audit as a peer of react-native-quick-base64. metro is the bundler - it is not in a consumer's runtime bundle, which is exactly what this job's comment says it means to exclude. Excluding it at install time doesn't work: bun audit resolves peers from the registry regardless of what is installed, so --omit=peer and [install] peer = false both leave the chain in the graph. --ignore is the only lever, and it is per-advisory, so anything new still fails the job. --- .github/workflows/validate-js.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/validate-js.yml b/.github/workflows/validate-js.yml index a8e9dc49..c5645d3b 100644 --- a/.github/workflows/validate-js.yml +++ b/.github/workflows/validate-js.yml @@ -89,6 +89,14 @@ jobs: # workspace's dev/peer tooling. Workspace-level `bun audit` walks through optional # peers (expo, react-native, etc.) which surface ~70 advisories that never reach a # consumer's runtime bundle. Phase 5.1 baseline: zero advisories in the runtime tree. + # + # `bun audit` resolves peers from the registry regardless of what is installed, so + # neither `--omit=peer` nor `[install] peer = false` keeps react-native's own tree + # out of the graph — only `--ignore` does. These two are metro (react-native's + # bundler, dev-time only) reached via the react-native peer of + # react-native-quick-base64; they are not in a consumer's runtime bundle. Ignores + # are per-advisory, so anything new still fails this job. Drop them once + # react-native ships a metro with image-size >= 2.0.3. - name: Audit runtime dependencies run: | mkdir -p /tmp/rnqc-runtime-audit @@ -96,7 +104,9 @@ jobs: bun -e "const pkg=require('$GITHUB_WORKSPACE/packages/react-native-quick-crypto/package.json'); require('fs').writeFileSync('package.json', JSON.stringify({name:'rnqc-runtime-audit',version:'0.0.0',dependencies:pkg.dependencies},null,2));" cat package.json bun install --no-summary - bun audit --audit-level=high + bun audit --audit-level=high \ + --ignore=GHSA-w3rx-r6r6-pgpr \ + --ignore=GHSA-5p2g-fcmc-qvqq lint_js: name: JS Lint (eslint, prettier)