From 90dbf9b3afa5dbb4e1a9ca900c0fde3277ea09c0 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 5 Jun 2026 08:57:05 +0200 Subject: [PATCH 1/2] fix: raise iOS deployment target during apply Ensure voltra apply raises React Native CLI iOS app deployment targets safely while preserving compatible manual Podfile and Xcode overrides. --- .changeset/tall-maps-spark.md | 5 + packages/cli/src/platforms/ios/apply.ts | 2 +- .../cli/src/platforms/ios/deploymentTarget.ts | 47 +++++++ packages/cli/src/platforms/ios/podfile.ts | 110 +++++++++++++++- packages/cli/src/platforms/ios/xcodeTarget.ts | 25 +++- packages/cli/test/cli.test.js | 123 ++++++++++++++++++ 6 files changed, 308 insertions(+), 4 deletions(-) create mode 100644 .changeset/tall-maps-spark.md create mode 100644 packages/cli/src/platforms/ios/deploymentTarget.ts diff --git a/.changeset/tall-maps-spark.md b/.changeset/tall-maps-spark.md new file mode 100644 index 00000000..cab4037d --- /dev/null +++ b/.changeset/tall-maps-spark.md @@ -0,0 +1,5 @@ +--- +'voltra': patch +--- + +Update `voltra apply` to safely raise React Native CLI iOS app deployment targets required by Voltra while preserving compatible manual overrides. diff --git a/packages/cli/src/platforms/ios/apply.ts b/packages/cli/src/platforms/ios/apply.ts index 60832cd2..c0ebc1b2 100644 --- a/packages/cli/src/platforms/ios/apply.ts +++ b/packages/cli/src/platforms/ios/apply.ts @@ -103,7 +103,7 @@ export async function applyIOSPlatform(context: PlatformApplyContext): Promise

rightPart) { + return 1 + } + + if (leftPart < rightPart) { + return -1 + } + } + + return 0 +} + +export function isIOSDeploymentTargetAtLeast(value: string, minimum: string): boolean | undefined { + const comparison = compareIOSDeploymentTargets(value, minimum) + return comparison === undefined ? undefined : comparison >= 0 +} + +export function maxIOSDeploymentTarget(left: string, right: string): string { + const comparison = compareIOSDeploymentTargets(left, right) + return comparison !== undefined && comparison >= 0 ? left : right +} + +function parseIOSDeploymentTarget(value: string): number[] | undefined { + const normalizedValue = value.trim().replace(/^"|"$/g, '').replace(/^'|'$/g, '') + + if (!/^\d+(?:\.\d+){0,2}$/.test(normalizedValue)) { + return undefined + } + + return normalizedValue.split('.').map((part) => Number(part)) +} diff --git a/packages/cli/src/platforms/ios/podfile.ts b/packages/cli/src/platforms/ios/podfile.ts index 85791244..d66fa04c 100644 --- a/packages/cli/src/platforms/ios/podfile.ts +++ b/packages/cli/src/platforms/ios/podfile.ts @@ -1,7 +1,11 @@ +import path from 'node:path' +import { createRequire } from 'node:module' + import { readTextFile, writeTextFile } from '../../fs/readWrite' import { toRelativePath } from '../../fs/path' import { VoltraCliError } from '../../reporting/summary' import { resolveIOSWidgetTargetName } from './targetName' +import { VOLTRA_MIN_IOS_DEPLOYMENT_TARGET, isIOSDeploymentTargetAtLeast } from './deploymentTarget' import type { IOSProjectDiscovery } from '../../discovery/ios' import type { NormalizedVoltraIOSConfig } from '../../config/types' @@ -21,6 +25,7 @@ export interface EnsurePodfileBlockOptions { export interface EnsurePodfileBlockResult { change?: ReportedChange targetName: string + warnings?: string[] } export class IOSPodfileMutationError extends VoltraCliError { @@ -35,10 +40,11 @@ export async function ensurePodfileBlock(options: EnsurePodfileBlockOptions): Pr const targetName = resolveIOSWidgetTargetName(ios, discovery) const currentContent = await readPodfile(discovery.podfilePath) const nextBlock = buildManagedPodfileBlock(targetName) - const nextContent = reconcilePodfile(currentContent, nextBlock, targetName) + const platformResult = await ensurePodfileDeploymentTarget(currentContent, projectRoot) + const nextContent = reconcilePodfile(platformResult.content, nextBlock, targetName) if (currentContent === nextContent) { - return { targetName } + return { targetName, warnings: platformResult.warnings } } await writeTextFile(discovery.podfilePath, nextContent) @@ -49,7 +55,107 @@ export async function ensurePodfileBlock(options: EnsurePodfileBlockOptions): Pr path: toRelativePath(projectRoot, discovery.podfilePath), }, targetName, + warnings: platformResult.warnings, + } +} + +interface EnsurePodfileDeploymentTargetResult { + content: string + warnings?: string[] +} + +async function ensurePodfileDeploymentTarget( + content: string, + projectRoot: string +): Promise { + const lines = content.split('\n') + const platformLineIndex = lines.findIndex((line) => /^\s*platform\s+:ios\s*,/.test(line)) + + if (platformLineIndex === -1) { + return { + content, + warnings: [ + `Could not find an iOS platform declaration in the Podfile. Ensure it resolves to iOS ${VOLTRA_MIN_IOS_DEPLOYMENT_TARGET} or newer.`, + ], + } + } + + const line = lines[platformLineIndex] + const match = line.match(/^(\s*platform\s+:ios\s*,\s*)(.*?)(\s*(?:#.*)?)$/) + + if (!match) { + return { + content, + warnings: [getUnknownPodfilePlatformWarning()], + } + } + + const [, prefix, rawValue, suffix] = match + const platformValue = rawValue.trim() + const nextValue = await resolveNextPodfilePlatformValue(platformValue, projectRoot) + + if (nextValue === undefined) { + return { + content, + warnings: [getUnknownPodfilePlatformWarning()], + } + } + + if (nextValue === platformValue) { + return { content } + } + + lines[platformLineIndex] = `${prefix}${nextValue}${suffix}` + return { content: lines.join('\n') } +} + +async function resolveNextPodfilePlatformValue( + platformValue: string, + projectRoot: string +): Promise { + const literalVersion = parseRubyStringLiteral(platformValue) + + if (literalVersion) { + return isIOSDeploymentTargetAtLeast(literalVersion, VOLTRA_MIN_IOS_DEPLOYMENT_TARGET) + ? platformValue + : `'${VOLTRA_MIN_IOS_DEPLOYMENT_TARGET}'` } + + if (platformValue === 'min_ios_version_supported') { + const reactNativeMinVersion = await resolveReactNativeMinIOSVersion(projectRoot) + + if ( + reactNativeMinVersion && + isIOSDeploymentTargetAtLeast(reactNativeMinVersion, VOLTRA_MIN_IOS_DEPLOYMENT_TARGET) + ) { + return platformValue + } + + return `'${VOLTRA_MIN_IOS_DEPLOYMENT_TARGET}'` + } + + return undefined +} + +function parseRubyStringLiteral(value: string): string | undefined { + const match = value.match(/^(['"])(\d+(?:\.\d+){0,2})\1$/) + return match?.[2] +} + +async function resolveReactNativeMinIOSVersion(projectRoot: string): Promise { + try { + const requireFromProject = createRequire(path.join(projectRoot, 'package.json')) + const reactNativePackagePath = requireFromProject.resolve('react-native/package.json') + const helpersPath = path.join(path.dirname(reactNativePackagePath), 'scripts', 'cocoapods', 'helpers.rb') + const helpersContent = await readTextFile(helpersPath) + return helpersContent.match(/def self\.min_ios_version_supported[\s\S]*?return ['"]([^'"]+)['"]/)?.[1] + } catch { + return undefined + } +} + +function getUnknownPodfilePlatformWarning(): string { + return `Could not verify the Podfile iOS platform declaration. Ensure it resolves to iOS ${VOLTRA_MIN_IOS_DEPLOYMENT_TARGET} or newer.` } function buildManagedPodfileBlock(targetName: string): string { diff --git a/packages/cli/src/platforms/ios/xcodeTarget.ts b/packages/cli/src/platforms/ios/xcodeTarget.ts index 173fc950..680be266 100644 --- a/packages/cli/src/platforms/ios/xcodeTarget.ts +++ b/packages/cli/src/platforms/ios/xcodeTarget.ts @@ -18,6 +18,7 @@ import { resolveIOSWidgetTargetName } from './targetName' import { ensureMainGroupChild, openIOSXcodeProject, saveIOSXcodeProject } from './xcode' import { resolveMainAppEntitlementsPath } from './mainAppEntitlements' import { needsEntitlementsMutation } from './entitlements' +import { VOLTRA_MIN_IOS_DEPLOYMENT_TARGET, maxIOSDeploymentTarget } from './deploymentTarget' import type { IOSProjectDiscovery } from '../../discovery/ios' import type { NormalizedVoltraIOSConfig } from '../../config/types' @@ -72,6 +73,7 @@ export async function ensureIOSWidgetTarget( removeStaleWidgetTargets(context, staleTargetNames) ensureMainAppEntitlementsBuildSetting(context, mainAppEntitlementsPath) + ensureMainAppDeploymentTarget(context) ensureWidgetTarget(context, targetName, bundleIdentifier, ios.deploymentTarget, codeSigning) const widgetTarget = getWidgetTarget(context, targetName) @@ -168,7 +170,7 @@ function ensureBuildConfigurations( target: PBXNativeTarget, targetName: string, bundleIdentifier: string, - deploymentTarget: string, + minimumDeploymentTarget: string, codeSigning: MainAppCodeSigningSettings ): void { const configurationList = target.props.buildConfigurationList @@ -180,6 +182,8 @@ function ensureBuildConfigurations( } for (const config of configurationList.props.buildConfigurations) { + const deploymentTarget = resolveBuildConfigurationDeploymentTarget(config, minimumDeploymentTarget) + Object.assign( config.props.buildSettings, buildWidgetBuildSettings(targetName, bundleIdentifier, deploymentTarget, codeSigning, config.props.name) @@ -777,6 +781,25 @@ function ensureMainAppEntitlementsBuildSetting( } } +function ensureMainAppDeploymentTarget(context: IOSXcodeProjectContext): void { + for (const config of context.mainAppTarget.buildConfigurations.all) { + config.props.buildSettings.IPHONEOS_DEPLOYMENT_TARGET = resolveBuildConfigurationDeploymentTarget( + config, + VOLTRA_MIN_IOS_DEPLOYMENT_TARGET + ) + } +} + +function resolveBuildConfigurationDeploymentTarget( + config: XCBuildConfiguration, + minimumDeploymentTarget: string +): string { + const currentDeploymentTarget = readBuildSettingString(config.props.buildSettings.IPHONEOS_DEPLOYMENT_TARGET) + return currentDeploymentTarget + ? maxIOSDeploymentTarget(currentDeploymentTarget, minimumDeploymentTarget) + : minimumDeploymentTarget +} + interface MainAppCodeSigningSettings { codeSignStyle?: string developmentTeam?: string diff --git a/packages/cli/test/cli.test.js b/packages/cli/test/cli.test.js index 9fcfa0e2..275d2e11 100644 --- a/packages/cli/test/cli.test.js +++ b/packages/cli/test/cli.test.js @@ -21,6 +21,52 @@ function writeFakePackage(projectRoot, packageName) { fs.writeFileSync(packagePath, `${JSON.stringify({ name: packageName, version: '0.0.0' }, null, 2)}\n`) } +function writeFakeReactNativeMinIOSVersion(projectRoot, version) { + writeFakePackage(projectRoot, 'react-native') + const helpersPath = path.join(projectRoot, 'node_modules', 'react-native', 'scripts', 'cocoapods', 'helpers.rb') + fs.mkdirSync(path.dirname(helpersPath), { recursive: true }) + fs.writeFileSync( + helpersPath, + `module Helpers + class Constants + def self.min_ios_version_supported + return '${version}' + end + end +end +` + ) +} + +function createIOSPodfileTestOptions(projectRoot, podfileContent) { + const iosRoot = path.join(projectRoot, 'ios') + const podfilePath = path.join(iosRoot, 'Podfile') + + fs.mkdirSync(iosRoot, { recursive: true }) + fs.writeFileSync(podfilePath, podfileContent) + + return { + projectRoot, + ios: { + deploymentTarget: '17.0', + enablePushNotifications: false, + fonts: [], + project: {}, + userImagesPath: path.join(projectRoot, 'assets', 'voltra'), + widgets: [], + }, + discovery: { + iosRoot, + xcodeprojPath: path.join(iosRoot, 'TestApp.xcodeproj'), + pbxprojPath: path.join(iosRoot, 'TestApp.xcodeproj', 'project.pbxproj'), + podfilePath, + mainTargetName: 'TestApp', + mainTargetCandidates: ['TestApp'], + infoPlistPath: path.join(iosRoot, 'TestApp', 'Info.plist'), + }, + } +} + test('apply help documents the yes flag', () => { const { getApplyHelpText } = loadCliModule() const helpText = getApplyHelpText() @@ -196,6 +242,83 @@ test('ensureEntitlements creates the main app entitlements file when it is missi assert.match(entitlements, /aps-environment/) }) +test('ensurePodfileBlock bumps literal iOS platform below Voltra minimum', async () => { + const { ensurePodfileBlock } = loadCliModule() + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-')) + const options = createIOSPodfileTestOptions(tempDir, "platform :ios, '15.1'\n") + + const result = await ensurePodfileBlock(options) + const podfile = fs.readFileSync(options.discovery.podfilePath, 'utf8') + + assert.equal(result.warnings, undefined) + assert.match(podfile, /^platform :ios, '16\.4'$/m) + assert.match(podfile, /VOLTRA MANAGED BLOCK/) +}) + +test('ensurePodfileBlock leaves compatible literal iOS platform untouched', async () => { + const { ensurePodfileBlock } = loadCliModule() + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-')) + const options = createIOSPodfileTestOptions(tempDir, 'platform :ios, "17.2" # custom floor\n') + + const result = await ensurePodfileBlock(options) + const podfile = fs.readFileSync(options.discovery.podfilePath, 'utf8') + + assert.equal(result.warnings, undefined) + assert.match(podfile, /^platform :ios, "17\.2" # custom floor$/m) +}) + +test('ensurePodfileBlock compares iOS platform versions numerically', async () => { + const { ensurePodfileBlock } = loadCliModule() + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-')) + const options = createIOSPodfileTestOptions(tempDir, "platform :ios, '16.10'\n") + + const result = await ensurePodfileBlock(options) + const podfile = fs.readFileSync(options.discovery.podfilePath, 'utf8') + + assert.equal(result.warnings, undefined) + assert.match(podfile, /^platform :ios, '16\.10'$/m) +}) + +test('ensurePodfileBlock bumps React Native min_ios_version_supported when it is below Voltra minimum', async () => { + const { ensurePodfileBlock } = loadCliModule() + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-')) + writeFakeReactNativeMinIOSVersion(tempDir, '15.1') + const options = createIOSPodfileTestOptions(tempDir, 'platform :ios, min_ios_version_supported\n') + + const result = await ensurePodfileBlock(options) + const podfile = fs.readFileSync(options.discovery.podfilePath, 'utf8') + + assert.equal(result.warnings, undefined) + assert.match(podfile, /^platform :ios, '16\.4'$/m) +}) + +test('ensurePodfileBlock leaves React Native min_ios_version_supported when it is already compatible', async () => { + const { ensurePodfileBlock } = loadCliModule() + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-')) + writeFakeReactNativeMinIOSVersion(tempDir, '17.0') + const options = createIOSPodfileTestOptions(tempDir, 'platform :ios, min_ios_version_supported\n') + + const result = await ensurePodfileBlock(options) + const podfile = fs.readFileSync(options.discovery.podfilePath, 'utf8') + + assert.equal(result.warnings, undefined) + assert.match(podfile, /^platform :ios, min_ios_version_supported$/m) +}) + +test('ensurePodfileBlock warns for unknown iOS platform expressions', async () => { + const { ensurePodfileBlock } = loadCliModule() + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-')) + const options = createIOSPodfileTestOptions(tempDir, 'platform :ios, ENV.fetch("IOS_DEPLOYMENT_TARGET")\n') + + const result = await ensurePodfileBlock(options) + const podfile = fs.readFileSync(options.discovery.podfilePath, 'utf8') + + assert.deepEqual(result.warnings, [ + 'Could not verify the Podfile iOS platform declaration. Ensure it resolves to iOS 16.4 or newer.', + ]) + assert.match(podfile, /^platform :ios, ENV\.fetch\("IOS_DEPLOYMENT_TARGET"\)$/m) +}) + test('android preflight reports missing client package when renderer is installed', async () => { const { createAndroidPreflightRunner } = loadCliModule() const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-')) From c55f0d903889591fb2dda8dcdc425ac1608b41fd Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 5 Jun 2026 09:02:55 +0200 Subject: [PATCH 2/2] chore: remove cli changeset Remove the changeset because the Voltra CLI apply flow is not released yet. --- .changeset/tall-maps-spark.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/tall-maps-spark.md diff --git a/.changeset/tall-maps-spark.md b/.changeset/tall-maps-spark.md deleted file mode 100644 index cab4037d..00000000 --- a/.changeset/tall-maps-spark.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'voltra': patch ---- - -Update `voltra apply` to safely raise React Native CLI iOS app deployment targets required by Voltra while preserving compatible manual overrides.