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 new file mode 100644 index 0000000000..5c43e21eda --- /dev/null +++ b/packages/build-tools/src/ios/__tests__/schemeCollision.test.ts @@ -0,0 +1,40 @@ +import { vol } from 'memfs'; + +import { assertNoPodSchemeNameCollision } from '../schemeCollision'; + +describe(assertNoPodSchemeNameCollision, () => { + const projectRoot = '/app'; + + it('does not throw when there is no pod scheme collision', () => { + vol.fromJSON( + { + 'ios/testapp.xcodeproj/xcshareddata/xcschemes/FruitVision.xcscheme': 'fakecontents', + }, + projectRoot + ); + + expect(() => + assertNoPodSchemeNameCollision({ + projectDir: projectRoot, + buildScheme: 'FruitVision', + }) + ).not.toThrow(); + }); + + it('throws when pod scheme name collides with app scheme', () => { + vol.fromJSON( + { + 'ios/testapp.xcodeproj/xcshareddata/xcschemes/FruitVision.xcscheme': 'fakecontents', + 'ios/Pods/Pods.xcodeproj/xcshareddata/xcschemes/FruitVision.xcscheme': 'fakecontents', + }, + projectRoot + ); + + expect(() => + assertNoPodSchemeNameCollision({ + projectDir: projectRoot, + buildScheme: 'FruitVision', + }) + ).toThrow(/scheme name collision/); + }); +}); 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 new file mode 100644 index 0000000000..9ba9c32282 --- /dev/null +++ b/packages/build-tools/src/ios/schemeCollision.ts @@ -0,0 +1,32 @@ +import { UserFacingError } from '@expo/eas-build-job/dist/errors'; +import fs from 'fs-extra'; +import path from 'path'; + +export function assertNoPodSchemeNameCollision({ + projectDir, + buildScheme, +}: { + projectDir: string; + buildScheme: string; +}): void { + const podSchemePath = path.join( + projectDir, + 'ios', + 'Pods', + 'Pods.xcodeproj', + 'xcshareddata', + 'xcschemes', + `${buildScheme}.xcscheme` + ); + if (fs.existsSync(podSchemePath)) { + throw new UserFacingError( + 'SCHEME_NAME_COLLISION', + `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 (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 a8e82ed1d1..63170fb965 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, buildScheme: '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, 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 f5b6e708e0..c4b08d3158 100644 --- a/packages/eas-cli/src/project/ios/scheme.ts +++ b/packages/eas-cli/src/project/ios/scheme.ts @@ -2,7 +2,10 @@ 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'; import Log from '../../log'; import { promptAsync } from '../../prompts'; @@ -32,6 +35,7 @@ export async function resolveXcodeBuildContextAsync( projectDir, nonInteractive, })); + await warnIfPodSchemeNameCollisionAsync({ projectDir, buildScheme }); return { buildScheme, buildConfiguration: @@ -56,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, @@ -97,3 +120,33 @@ export async function selectSchemeAsync({ return selectedScheme as string; } } + +export async function assertNoPodSchemeNameCollisionAsync( + { + projectDir, + buildScheme, + }: { + 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 (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.' + ); + } +}