From d908db1d0f3b14691d4865a6c55a45ae16ca7200 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Tue, 17 Feb 2026 10:37:44 +0100 Subject: [PATCH 1/2] Prevent Pod scheme collisions --- .../src/ios/__tests__/schemeCollision.test.ts | 34 +++++++++++++++++++ packages/build-tools/src/ios/fastlane.ts | 2 ++ .../build-tools/src/ios/schemeCollision.ts | 29 ++++++++++++++++ .../src/project/ios/__tests__/scheme-test.ts | 33 +++++++++++++++++- packages/eas-cli/src/project/ios/scheme.ts | 28 +++++++++++++++ 5 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 packages/build-tools/src/ios/__tests__/schemeCollision.test.ts create mode 100644 packages/build-tools/src/ios/schemeCollision.ts diff --git a/packages/build-tools/src/ios/__tests__/schemeCollision.test.ts b/packages/build-tools/src/ios/__tests__/schemeCollision.test.ts new file mode 100644 index 0000000000..5a91db543b --- /dev/null +++ b/packages/build-tools/src/ios/__tests__/schemeCollision.test.ts @@ -0,0 +1,34 @@ +import { vol } from 'memfs'; + +import { assertNoPodSchemeNameCollisionAsync } from '../schemeCollision'; + +describe(assertNoPodSchemeNameCollisionAsync, () => { + const projectRoot = '/app'; + + it('does not throw when there is no pod scheme collision', async () => { + vol.fromJSON( + { + 'ios/testapp.xcodeproj/xcshareddata/xcschemes/FruitVision.xcscheme': 'fakecontents', + }, + projectRoot + ); + + await expect(assertNoPodSchemeNameCollisionAsync(projectRoot, 'FruitVision')).resolves.toBe( + undefined + ); + }); + + it('throws when pod scheme name collides with app scheme', async () => { + vol.fromJSON( + { + 'ios/testapp.xcodeproj/xcshareddata/xcschemes/FruitVision.xcscheme': 'fakecontents', + 'ios/Pods/Pods.xcodeproj/xcshareddata/xcschemes/FruitVision.xcscheme': 'fakecontents', + }, + projectRoot + ); + + await expect(assertNoPodSchemeNameCollisionAsync(projectRoot, 'FruitVision')).rejects.toThrow( + /scheme name collision/ + ); + }); +}); diff --git a/packages/build-tools/src/ios/fastlane.ts b/packages/build-tools/src/ios/fastlane.ts index 9878035cd3..a0fbb8039e 100644 --- a/packages/build-tools/src/ios/fastlane.ts +++ b/packages/build-tools/src/ios/fastlane.ts @@ -8,6 +8,7 @@ import path from 'path'; import type { Credentials } from './credentials/manager'; import { createFastfileForResigningBuild } from './fastfile'; import { createGymfileForArchiveBuild, createGymfileForSimulatorBuild } from './gymfile'; +import { assertNoPodSchemeNameCollisionAsync } from './schemeCollision'; import { isTVOS } from './tvos'; import { XcodeBuildLogger } from './xcpretty'; import { COMMON_FASTLANE_ENV } from '../common/fastlane'; @@ -29,6 +30,7 @@ export async function runFastlaneGym( extraEnv?: Env; } ): Promise { + await assertNoPodSchemeNameCollisionAsync(ctx.getReactNativeProjectDirectory(), scheme); await ensureGymfileExists(ctx, { scheme, buildConfiguration, diff --git a/packages/build-tools/src/ios/schemeCollision.ts b/packages/build-tools/src/ios/schemeCollision.ts new file mode 100644 index 0000000000..ced4893abd --- /dev/null +++ b/packages/build-tools/src/ios/schemeCollision.ts @@ -0,0 +1,29 @@ +import { UserFacingError } from '@expo/eas-build-job/dist/errors'; +import fs from 'fs-extra'; +import path from 'path'; + +export async function assertNoPodSchemeNameCollisionAsync( + projectRoot: string, + scheme: string +): Promise { + const podSchemePath = path.join( + projectRoot, + 'ios', + 'Pods', + 'Pods.xcodeproj', + 'xcshareddata', + 'xcschemes', + `${scheme}.xcscheme` + ); + if (await fs.pathExists(podSchemePath)) { + throw new UserFacingError( + 'SCHEME_NAME_COLLISION', + `Detected an iOS scheme name collision for "${scheme}".\n` + + `A CocoaPods shared scheme with the same name exists at: ${podSchemePath}\n\n` + + 'This is unsafe because Xcode may resolve the Pods scheme instead of your application scheme.\n\n' + + 'To fix this:\n' + + '- If you use CNG: set "ios.scheme" in eas.json to a non-conflicting app scheme name.\n' + + "- If you don't use CNG: rename the app scheme in Xcode so it does not match the Pod scheme name, then update build config to use that scheme." + ); + } +} diff --git a/packages/eas-cli/src/project/ios/__tests__/scheme-test.ts b/packages/eas-cli/src/project/ios/__tests__/scheme-test.ts index a8e82ed1d1..28227161c5 100644 --- a/packages/eas-cli/src/project/ios/__tests__/scheme-test.ts +++ b/packages/eas-cli/src/project/ios/__tests__/scheme-test.ts @@ -3,7 +3,7 @@ import { vol } from 'memfs'; import os from 'os'; import { promptAsync } from '../../../prompts'; -import { selectSchemeAsync } from '../scheme'; +import { assertNoPodSchemeNameCollisionAsync, selectSchemeAsync } from '../scheme'; jest.mock('fs'); jest.mock('../../../prompts'); @@ -67,3 +67,34 @@ describe(selectSchemeAsync, () => { }); }); }); + +describe(assertNoPodSchemeNameCollisionAsync, () => { + const projectDir = '/app'; + + it('does not throw when there is no pod scheme collision', async () => { + vol.fromJSON( + { + 'ios/multitarget.xcodeproj/xcshareddata/xcschemes/FruitVision.xcscheme': 'fakecontents', + }, + projectDir + ); + + await expect(assertNoPodSchemeNameCollisionAsync(projectDir, 'FruitVision')).resolves.toBe( + undefined + ); + }); + + it('throws when pod scheme name collides with app scheme', async () => { + vol.fromJSON( + { + 'ios/multitarget.xcodeproj/xcshareddata/xcschemes/FruitVision.xcscheme': 'fakecontents', + 'ios/Pods/Pods.xcodeproj/xcshareddata/xcschemes/FruitVision.xcscheme': 'fakecontents', + }, + projectDir + ); + + await expect(assertNoPodSchemeNameCollisionAsync(projectDir, 'FruitVision')).rejects.toThrow( + /scheme name collision/ + ); + }); +}); diff --git a/packages/eas-cli/src/project/ios/scheme.ts b/packages/eas-cli/src/project/ios/scheme.ts index f5b6e708e0..b8010c2cd7 100644 --- a/packages/eas-cli/src/project/ios/scheme.ts +++ b/packages/eas-cli/src/project/ios/scheme.ts @@ -3,6 +3,8 @@ import { IOSConfig } from '@expo/config-plugins'; import { Platform, Workflow } from '@expo/eas-build-job'; import { BuildProfile } from '@expo/eas-json'; import chalk from 'chalk'; +import fs from 'fs-extra'; +import path from 'path'; import Log from '../../log'; import { promptAsync } from '../../prompts'; @@ -32,6 +34,7 @@ export async function resolveXcodeBuildContextAsync( projectDir, nonInteractive, })); + await assertNoPodSchemeNameCollisionAsync(projectDir, buildScheme); return { buildScheme, buildConfiguration: @@ -97,3 +100,28 @@ export async function selectSchemeAsync({ return selectedScheme as string; } } + +export async function assertNoPodSchemeNameCollisionAsync( + projectDir: string, + buildScheme: string +): Promise { + const podSchemePath = path.join( + projectDir, + 'ios', + 'Pods', + 'Pods.xcodeproj', + 'xcshareddata', + 'xcschemes', + `${buildScheme}.xcscheme` + ); + if (await fs.pathExists(podSchemePath)) { + throw new Error( + `Detected an iOS scheme name collision for "${buildScheme}".\n` + + `A CocoaPods shared scheme with the same name exists at: ${podSchemePath}\n\n` + + 'This is unsafe because Xcode may resolve the Pods scheme instead of your application scheme.\n\n' + + 'To fix this:\n' + + '- If you use CNG: set "ios.scheme" in eas.json to a non-conflicting app scheme name.\n' + + "- If you don't use CNG: rename the app scheme in Xcode so it does not match the Pod scheme name, then update build config to use that scheme." + ); + } +} From 1dac34b3973ee5fd582378942fca06a703b3b086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Wed, 18 Feb 2026 10:37:19 +0100 Subject: [PATCH 2/2] Adjust pod scheme collision checks --- packages/build-tools/src/builders/ios.ts | 2 +- .../src/ios/__tests__/schemeCollision.test.ts | 26 +++++++----- packages/build-tools/src/ios/fastlane.ts | 2 - packages/build-tools/src/ios/resolve.ts | 41 +++++++++++++++++-- .../build-tools/src/ios/schemeCollision.ts | 23 ++++++----- packages/build-tools/src/ios/tvos.ts | 2 +- .../src/project/ios/__tests__/scheme-test.ts | 12 +++--- packages/eas-cli/src/project/ios/scheme.ts | 35 +++++++++++++--- 8 files changed, 104 insertions(+), 39 deletions(-) diff --git a/packages/build-tools/src/builders/ios.ts b/packages/build-tools/src/builders/ios.ts index 4ee9528627..5f37e10ea4 100644 --- a/packages/build-tools/src/builders/ios.ts +++ b/packages/build-tools/src/builders/ios.ts @@ -147,7 +147,7 @@ async function buildAsync(ctx: BuildContext): Promise { } await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => { - const scheme = resolveScheme(ctx); + const scheme = await resolveScheme(ctx); const entitlements = await readEntitlementsAsync(ctx, { scheme, buildConfiguration }); await runFastlaneGym(ctx, { credentials, diff --git a/packages/build-tools/src/ios/__tests__/schemeCollision.test.ts b/packages/build-tools/src/ios/__tests__/schemeCollision.test.ts index 5a91db543b..5c43e21eda 100644 --- a/packages/build-tools/src/ios/__tests__/schemeCollision.test.ts +++ b/packages/build-tools/src/ios/__tests__/schemeCollision.test.ts @@ -1,11 +1,11 @@ import { vol } from 'memfs'; -import { assertNoPodSchemeNameCollisionAsync } from '../schemeCollision'; +import { assertNoPodSchemeNameCollision } from '../schemeCollision'; -describe(assertNoPodSchemeNameCollisionAsync, () => { +describe(assertNoPodSchemeNameCollision, () => { const projectRoot = '/app'; - it('does not throw when there is no pod scheme collision', async () => { + it('does not throw when there is no pod scheme collision', () => { vol.fromJSON( { 'ios/testapp.xcodeproj/xcshareddata/xcschemes/FruitVision.xcscheme': 'fakecontents', @@ -13,12 +13,15 @@ describe(assertNoPodSchemeNameCollisionAsync, () => { projectRoot ); - await expect(assertNoPodSchemeNameCollisionAsync(projectRoot, 'FruitVision')).resolves.toBe( - undefined - ); + expect(() => + assertNoPodSchemeNameCollision({ + projectDir: projectRoot, + buildScheme: 'FruitVision', + }) + ).not.toThrow(); }); - it('throws when pod scheme name collides with app scheme', async () => { + it('throws when pod scheme name collides with app scheme', () => { vol.fromJSON( { 'ios/testapp.xcodeproj/xcshareddata/xcschemes/FruitVision.xcscheme': 'fakecontents', @@ -27,8 +30,11 @@ describe(assertNoPodSchemeNameCollisionAsync, () => { projectRoot ); - await expect(assertNoPodSchemeNameCollisionAsync(projectRoot, 'FruitVision')).rejects.toThrow( - /scheme name collision/ - ); + expect(() => + assertNoPodSchemeNameCollision({ + projectDir: projectRoot, + buildScheme: 'FruitVision', + }) + ).toThrow(/scheme name collision/); }); }); diff --git a/packages/build-tools/src/ios/fastlane.ts b/packages/build-tools/src/ios/fastlane.ts index a0fbb8039e..9878035cd3 100644 --- a/packages/build-tools/src/ios/fastlane.ts +++ b/packages/build-tools/src/ios/fastlane.ts @@ -8,7 +8,6 @@ import path from 'path'; import type { Credentials } from './credentials/manager'; import { createFastfileForResigningBuild } from './fastfile'; import { createGymfileForArchiveBuild, createGymfileForSimulatorBuild } from './gymfile'; -import { assertNoPodSchemeNameCollisionAsync } from './schemeCollision'; import { isTVOS } from './tvos'; import { XcodeBuildLogger } from './xcpretty'; import { COMMON_FASTLANE_ENV } from '../common/fastlane'; @@ -30,7 +29,6 @@ export async function runFastlaneGym( extraEnv?: Env; } ): Promise { - await assertNoPodSchemeNameCollisionAsync(ctx.getReactNativeProjectDirectory(), scheme); await ensureGymfileExists(ctx, { scheme, buildConfiguration, diff --git a/packages/build-tools/src/ios/resolve.ts b/packages/build-tools/src/ios/resolve.ts index 70ab5ceeab..c23e1b4dce 100644 --- a/packages/build-tools/src/ios/resolve.ts +++ b/packages/build-tools/src/ios/resolve.ts @@ -1,20 +1,53 @@ import { IOSConfig } from '@expo/config-plugins'; import { Ios } from '@expo/eas-build-job'; +import { asyncResult } from '@expo/results'; import assert from 'assert'; +import { assertNoPodSchemeNameCollision } from './schemeCollision'; import { BuildContext } from '../context'; -export function resolveScheme(ctx: BuildContext): string { +export async function resolveScheme(ctx: BuildContext): Promise { + const projectDir = ctx.getReactNativeProjectDirectory(); if (ctx.job.scheme) { + await warnIfPodSchemeNameCollisionAsync({ + ctx, + projectDir, + buildScheme: ctx.job.scheme, + }); return ctx.job.scheme; } - const schemes = IOSConfig.BuildScheme.getSchemesFromXcodeproj( - ctx.getReactNativeProjectDirectory() - ); + const schemes = IOSConfig.BuildScheme.getSchemesFromXcodeproj(projectDir); assert(schemes.length === 1, 'Ejected project should have exactly one scheme'); + await warnIfPodSchemeNameCollisionAsync({ + ctx, + projectDir, + buildScheme: schemes[0], + }); return schemes[0]; } +async function warnIfPodSchemeNameCollisionAsync({ + ctx, + projectDir, + buildScheme, +}: { + ctx: BuildContext; + projectDir: string; + buildScheme: string; +}): Promise { + const collisionCheckResult = await asyncResult( + (async () => assertNoPodSchemeNameCollision({ projectDir, buildScheme }))() + ); + if (!collisionCheckResult.ok) { + ctx.logger.warn( + { err: collisionCheckResult.reason }, + `Detected an iOS scheme name collision for "${buildScheme}". ` + + 'Xcode may select a Pods scheme instead of the app scheme. Continuing with the selected scheme.' + ); + ctx.markBuildPhaseHasWarnings(); + } +} + export function resolveArtifactPath(ctx: BuildContext): string { if (ctx.job.applicationArchivePath) { return ctx.job.applicationArchivePath; diff --git a/packages/build-tools/src/ios/schemeCollision.ts b/packages/build-tools/src/ios/schemeCollision.ts index ced4893abd..9ba9c32282 100644 --- a/packages/build-tools/src/ios/schemeCollision.ts +++ b/packages/build-tools/src/ios/schemeCollision.ts @@ -2,28 +2,31 @@ import { UserFacingError } from '@expo/eas-build-job/dist/errors'; import fs from 'fs-extra'; import path from 'path'; -export async function assertNoPodSchemeNameCollisionAsync( - projectRoot: string, - scheme: string -): Promise { +export function assertNoPodSchemeNameCollision({ + projectDir, + buildScheme, +}: { + projectDir: string; + buildScheme: string; +}): void { const podSchemePath = path.join( - projectRoot, + projectDir, 'ios', 'Pods', 'Pods.xcodeproj', 'xcshareddata', 'xcschemes', - `${scheme}.xcscheme` + `${buildScheme}.xcscheme` ); - if (await fs.pathExists(podSchemePath)) { + if (fs.existsSync(podSchemePath)) { throw new UserFacingError( 'SCHEME_NAME_COLLISION', - `Detected an iOS scheme name collision for "${scheme}".\n` + + `Detected an iOS scheme name collision for "${buildScheme}".\n` + `A CocoaPods shared scheme with the same name exists at: ${podSchemePath}\n\n` + 'This is unsafe because Xcode may resolve the Pods scheme instead of your application scheme.\n\n' + 'To fix this:\n' + - '- If you use CNG: set "ios.scheme" in eas.json to a non-conflicting app scheme name.\n' + - "- If you don't use CNG: rename the app scheme in Xcode so it does not match the Pod scheme name, then update build config to use that scheme." + '- If you use CNG (managed workflow): create a non-conflicting iOS build scheme via a config plugin or prebuild script, then set "build.ios.scheme" in eas.json to that scheme.\n' + + '- If you do not use CNG (native ios/ directory): rename the app scheme in Xcode so it does not match the Pod scheme name, then set "build.ios.scheme" in eas.json to that scheme.' ); } } diff --git a/packages/build-tools/src/ios/tvos.ts b/packages/build-tools/src/ios/tvos.ts index a0873c2bf5..f8614f26db 100644 --- a/packages/build-tools/src/ios/tvos.ts +++ b/packages/build-tools/src/ios/tvos.ts @@ -13,7 +13,7 @@ import { BuildContext } from '../context'; * @returns true if this is an Apple TV configuration, false otherwise */ export async function isTVOS(ctx: BuildContext): Promise { - const scheme = resolveScheme(ctx); + const scheme = await resolveScheme(ctx); const project = IOSConfig.XcodeUtils.getPbxproj(ctx.getReactNativeProjectDirectory()); diff --git a/packages/eas-cli/src/project/ios/__tests__/scheme-test.ts b/packages/eas-cli/src/project/ios/__tests__/scheme-test.ts index 28227161c5..63170fb965 100644 --- a/packages/eas-cli/src/project/ios/__tests__/scheme-test.ts +++ b/packages/eas-cli/src/project/ios/__tests__/scheme-test.ts @@ -79,9 +79,9 @@ describe(assertNoPodSchemeNameCollisionAsync, () => { projectDir ); - await expect(assertNoPodSchemeNameCollisionAsync(projectDir, 'FruitVision')).resolves.toBe( - undefined - ); + await expect( + assertNoPodSchemeNameCollisionAsync({ projectDir, buildScheme: 'FruitVision' }) + ).resolves.toBe(undefined); }); it('throws when pod scheme name collides with app scheme', async () => { @@ -93,8 +93,8 @@ describe(assertNoPodSchemeNameCollisionAsync, () => { projectDir ); - await expect(assertNoPodSchemeNameCollisionAsync(projectDir, 'FruitVision')).rejects.toThrow( - /scheme name collision/ - ); + await expect( + assertNoPodSchemeNameCollisionAsync({ projectDir, buildScheme: 'FruitVision' }) + ).rejects.toThrow(/scheme name collision/); }); }); diff --git a/packages/eas-cli/src/project/ios/scheme.ts b/packages/eas-cli/src/project/ios/scheme.ts index b8010c2cd7..c4b08d3158 100644 --- a/packages/eas-cli/src/project/ios/scheme.ts +++ b/packages/eas-cli/src/project/ios/scheme.ts @@ -2,6 +2,7 @@ import { ExpoConfig } from '@expo/config'; import { IOSConfig } from '@expo/config-plugins'; import { Platform, Workflow } from '@expo/eas-build-job'; import { BuildProfile } from '@expo/eas-json'; +import { asyncResult } from '@expo/results'; import chalk from 'chalk'; import fs from 'fs-extra'; import path from 'path'; @@ -34,7 +35,7 @@ export async function resolveXcodeBuildContextAsync( projectDir, nonInteractive, })); - await assertNoPodSchemeNameCollisionAsync(projectDir, buildScheme); + await warnIfPodSchemeNameCollisionAsync({ projectDir, buildScheme }); return { buildScheme, buildConfiguration: @@ -59,6 +60,25 @@ export async function resolveXcodeBuildContextAsync( } } +async function warnIfPodSchemeNameCollisionAsync({ + projectDir, + buildScheme, +}: { + projectDir: string; + buildScheme: string; +}): Promise { + const collisionCheckResult = await asyncResult( + assertNoPodSchemeNameCollisionAsync({ projectDir, buildScheme }) + ); + if (!collisionCheckResult.ok) { + Log.warn( + collisionCheckResult.reason instanceof Error + ? collisionCheckResult.reason.message + : String(collisionCheckResult.reason) + ); + } +} + export async function selectSchemeAsync({ projectDir, nonInteractive = false, @@ -102,8 +122,13 @@ export async function selectSchemeAsync({ } export async function assertNoPodSchemeNameCollisionAsync( - projectDir: string, - buildScheme: string + { + projectDir, + buildScheme, + }: { + projectDir: string; + buildScheme: string; + } ): Promise { const podSchemePath = path.join( projectDir, @@ -120,8 +145,8 @@ export async function assertNoPodSchemeNameCollisionAsync( `A CocoaPods shared scheme with the same name exists at: ${podSchemePath}\n\n` + 'This is unsafe because Xcode may resolve the Pods scheme instead of your application scheme.\n\n' + 'To fix this:\n' + - '- If you use CNG: set "ios.scheme" in eas.json to a non-conflicting app scheme name.\n' + - "- If you don't use CNG: rename the app scheme in Xcode so it does not match the Pod scheme name, then update build config to use that scheme." + '- If you use CNG (managed workflow): create a non-conflicting iOS build scheme via a config plugin or prebuild script, then set "build.ios.scheme" in eas.json to that scheme.\n' + + '- If you do not use CNG (native ios/ directory): rename the app scheme in Xcode so it does not match the Pod scheme name, then set "build.ios.scheme" in eas.json to that scheme.' ); } }