From 4bde12efbbdb3150d887e2dd34c4d83e42a889f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Czaj=C4=99cki?= Date: Fri, 14 Aug 2026 14:09:01 +0300 Subject: [PATCH] [eas-cli] set up TestFlight group for existing ASC apps Set up the internal TestFlight group during interactive iOS submissions when ascAppId is configured, with an explicit opt-out flag. Skip setup in non-interactive mode, reuse the shared setup implementation, and fix the TestFlight group URL shown on partial tester failures. --- CHANGELOG.md | 1 + packages/eas-cli/src/commands/go.ts | 59 +------ packages/eas-cli/src/commands/submit.ts | 10 ++ .../__tests__/ensureTestFlightGroup-test.ts | 105 ++++++++++++ .../ios/appstore/ensureTestFlightGroup.ts | 35 ++-- packages/eas-cli/src/submit/context.ts | 3 + packages/eas-cli/src/submit/ios/AppProduce.ts | 2 +- .../src/submit/ios/IosSubmitCommand.ts | 2 + .../ios/__tests__/IosSubmitCommand-test.ts | 9 ++ .../__tests__/ensureTestFlightSetup-test.ts | 153 ++++++++++++++++++ .../src/submit/ios/ensureTestFlightSetup.ts | 100 ++++++++++++ 11 files changed, 411 insertions(+), 68 deletions(-) create mode 100644 packages/eas-cli/src/credentials/ios/appstore/__tests__/ensureTestFlightGroup-test.ts create mode 100644 packages/eas-cli/src/submit/ios/__tests__/ensureTestFlightSetup-test.ts create mode 100644 packages/eas-cli/src/submit/ios/ensureTestFlightSetup.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fe6b872948..6bb0f53e49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐Ÿ› Bug fixes - [eas-cli] Make the non-interactive "EAS project not configured" error actionable on every path: list the exact `eas init --id` / `eas init --account` commands and the accounts you can create projects in, instead of suggesting bare `eas init`, which fails the same way. ([#4153](https://github.com/expo/eas-cli/pull/4153) by [@williamgrosset](https://github.com/williamgrosset)) +- [eas-cli] Set up the internal TestFlight group and invite admin testers when submitting interactively with an existing `ascAppId`, with `--no-auto-testflight-setup` to disable it, and fix the TestFlight group URL printed when adding testers partially fails. ([#4136](https://github.com/expo/eas-cli/pull/4136) by [@tchayen](https://github.com/tchayen)) ### ๐Ÿงน Chores diff --git a/packages/eas-cli/src/commands/go.ts b/packages/eas-cli/src/commands/go.ts index a60b716295..606981920b 100644 --- a/packages/eas-cli/src/commands/go.ts +++ b/packages/eas-cli/src/commands/go.ts @@ -1,5 +1,5 @@ import { ExpoConfig, getConfigFilePaths } from '@expo/config'; -import { App, User, UserRole } from '@expo/apple-utils'; +import { App } from '@expo/apple-utils'; import { Flags } from '@oclif/core'; import chalk from 'chalk'; import * as fs from 'fs-extra'; @@ -15,6 +15,7 @@ import { SetUpAscApiKey } from '../credentials/ios/actions/SetUpAscApiKey'; import { SetUpBuildCredentials } from '../credentials/ios/actions/SetUpBuildCredentials'; import { SetUpPushKey } from '../credentials/ios/actions/SetUpPushKey'; import { ensureAppExistsAsync } from '../credentials/ios/appstore/ensureAppExists'; +import { ensureTestFlightGroupExistsAsync } from '../credentials/ios/appstore/ensureTestFlightGroup'; import { Target } from '../credentials/ios/types'; import { WorkflowJobStatus, @@ -60,62 +61,8 @@ export async function detectProjectSdkVersionAsync( } } -const TESTFLIGHT_GROUP_NAME = 'Team (Expo)'; - async function setupTestFlightAsync(ascApp: App): Promise { - let group; - for (let attempt = 0; attempt < 10; attempt++) { - try { - const groups = await ascApp.getBetaGroupsAsync({ - query: { includes: ['betaTesters'] }, - }); - - group = groups.find( - g => g.attributes.isInternalGroup && g.attributes.name === TESTFLIGHT_GROUP_NAME - ); - - if (!group) { - group = await ascApp.createBetaGroupAsync({ - name: TESTFLIGHT_GROUP_NAME, - isInternalGroup: true, - hasAccessToAllBuilds: true, - }); - } - break; - } catch (error: any) { - // Apple returns this error when the app isn't ready yet - if (error?.data?.errors?.some((e: any) => e.code === 'ENTITY_ERROR.RELATIONSHIP.INVALID')) { - if (attempt < 9) { - await sleepAsync(10_000); - continue; - } - } - throw error; - } - } - - if (!group) { - throw new Error('Failed to create TestFlight group'); - } - - const users = await User.getAsync(ascApp.context); - const admins = users.filter(u => u.attributes.roles?.includes(UserRole.ADMIN)); - - const existingEmails = new Set( - group.attributes.betaTesters?.map((t: any) => t.attributes.email?.toLowerCase()) ?? [] - ); - - const newTesters = admins - .filter(u => u.attributes.email && !existingEmails.has(u.attributes.email.toLowerCase())) - .map(u => ({ - email: u.attributes.email!, - firstName: u.attributes.firstName ?? '', - lastName: u.attributes.lastName ?? '', - })); - - if (newTesters.length > 0) { - await group.createBulkBetaTesterAssignmentsAsync(newTesters); - } + await ensureTestFlightGroupExistsAsync(ascApp); } /* eslint-disable no-console */ diff --git a/packages/eas-cli/src/commands/submit.ts b/packages/eas-cli/src/commands/submit.ts index bb5aff383a..22e8782e73 100644 --- a/packages/eas-cli/src/commands/submit.ts +++ b/packages/eas-cli/src/commands/submit.ts @@ -34,6 +34,7 @@ interface RawCommandFlags { verbose: boolean; wait: boolean; 'non-interactive': boolean; + 'auto-testflight-setup': boolean; 'verbose-fastlane': boolean; groups?: string[]; } @@ -46,6 +47,7 @@ interface CommandFlags { verbose: boolean; wait: boolean; nonInteractive: boolean; + autoTestFlightSetup: boolean; isVerboseFastlaneEnabled: boolean; groups?: string[]; } @@ -106,6 +108,11 @@ export default class Submit extends EasCommand { default: false, description: 'Run command in non-interactive mode', }), + 'auto-testflight-setup': Flags.boolean({ + default: true, + allowNo: true, + description: 'Set up an internal TestFlight group for the app (iOS only)', + }), }; static override contextDefinition = { @@ -152,6 +159,7 @@ export default class Submit extends EasCommand { profile: submissionProfile.profile, archiveFlags: flagsWithPlatform.archiveFlags, nonInteractive: flagsWithPlatform.nonInteractive, + autoTestFlightSetup: flagsWithPlatform.autoTestFlightSetup, isVerboseFastlaneEnabled: flagsWithPlatform.isVerboseFastlaneEnabled, groups: flagsWithPlatform.groups, actor, @@ -198,6 +206,7 @@ export default class Submit extends EasCommand { wait, profile, 'non-interactive': nonInteractive, + 'auto-testflight-setup': autoTestFlightSetup, 'verbose-fastlane': isVerboseFastlaneEnabled, groups, 'what-to-test': whatToTest, @@ -221,6 +230,7 @@ export default class Submit extends EasCommand { wait, profile, nonInteractive, + autoTestFlightSetup, whatToTest, isVerboseFastlaneEnabled, groups, diff --git a/packages/eas-cli/src/credentials/ios/appstore/__tests__/ensureTestFlightGroup-test.ts b/packages/eas-cli/src/credentials/ios/appstore/__tests__/ensureTestFlightGroup-test.ts new file mode 100644 index 0000000000..866834dfd2 --- /dev/null +++ b/packages/eas-cli/src/credentials/ios/appstore/__tests__/ensureTestFlightGroup-test.ts @@ -0,0 +1,105 @@ +import { App, BetaGroup, User } from '@expo/apple-utils'; + +import { ensureTestFlightGroupExistsAsync } from '../ensureTestFlightGroup'; +import { confirmAsync } from '../../../../prompts'; + +jest.mock('../../../../ora'); +jest.mock('../../../../prompts', () => ({ + confirmAsync: jest.fn(), +})); +jest.mock('@expo/apple-utils', () => ({ + ...jest.requireActual('@expo/apple-utils'), + User: { getAsync: jest.fn() }, + BetaGroup: { deleteAsync: jest.fn() }, +})); + +function mockApp({ + groups, + createdGroup, +}: { + groups: Partial[]; + createdGroup?: Partial; +}): App { + return { + id: '1234567890', + context: {}, + getBetaGroupsAsync: jest.fn().mockResolvedValue(groups), + createBetaGroupAsync: jest.fn().mockResolvedValue(createdGroup), + } as unknown as App; +} + +function mockGroup({ + hasAccessToAllBuilds, +}: { + hasAccessToAllBuilds: boolean; +}): Partial { + return { + id: 'group-id', + context: {} as BetaGroup['context'], + attributes: { + name: 'Team (Expo)', + isInternalGroup: true, + hasAccessToAllBuilds, + betaTesters: [], + } as unknown as BetaGroup['attributes'], + createBulkBetaTesterAssignmentsAsync: jest.fn(), + }; +} + +beforeEach(() => { + jest.mocked(confirmAsync).mockReset(); + jest.mocked(User.getAsync).mockReset().mockResolvedValue([]); + jest.mocked(BetaGroup.deleteAsync).mockReset(); +}); + +describe(ensureTestFlightGroupExistsAsync, () => { + it('skips setup when the app already has beta groups', async () => { + const app = mockApp({ groups: [mockGroup({ hasAccessToAllBuilds: true })] }); + + await ensureTestFlightGroupExistsAsync(app, { nonInteractive: true }); + + expect(app.createBetaGroupAsync).not.toHaveBeenCalled(); + expect(User.getAsync).not.toHaveBeenCalled(); + }); + + it('creates a group and adds admins without prompting in non-interactive mode', async () => { + const app = mockApp({ + groups: [], + createdGroup: mockGroup({ hasAccessToAllBuilds: true }), + }); + + await ensureTestFlightGroupExistsAsync(app, { nonInteractive: true }); + + expect(app.createBetaGroupAsync).toHaveBeenCalledWith({ + name: 'Team (Expo)', + isInternalGroup: true, + hasAccessToAllBuilds: true, + }); + expect(confirmAsync).not.toHaveBeenCalled(); + }); + + it('does not prompt or delete the group in non-interactive mode when it lacks access to all builds', async () => { + const app = mockApp({ + groups: [], + createdGroup: mockGroup({ hasAccessToAllBuilds: false }), + }); + + await ensureTestFlightGroupExistsAsync(app, { nonInteractive: true }); + + expect(confirmAsync).not.toHaveBeenCalled(); + expect(BetaGroup.deleteAsync).not.toHaveBeenCalled(); + }); + + it('prompts to regenerate the group in interactive mode when it lacks access to all builds', async () => { + jest.mocked(confirmAsync).mockResolvedValue(false); + const app = mockApp({ + groups: [], + createdGroup: mockGroup({ hasAccessToAllBuilds: false }), + }); + + await ensureTestFlightGroupExistsAsync(app, { nonInteractive: false }); + + expect(confirmAsync).toHaveBeenCalled(); + expect(BetaGroup.deleteAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eas-cli/src/credentials/ios/appstore/ensureTestFlightGroup.ts b/packages/eas-cli/src/credentials/ios/appstore/ensureTestFlightGroup.ts index 5f5189dfb6..f7ef0bb8c8 100644 --- a/packages/eas-cli/src/credentials/ios/appstore/ensureTestFlightGroup.ts +++ b/packages/eas-cli/src/credentials/ios/appstore/ensureTestFlightGroup.ts @@ -12,12 +12,10 @@ const AUTO_GROUP_NAME = 'Team (Expo)'; * Ensure a TestFlight internal group with access to all builds exists for the app and has all admin users invited to it. * This allows users to instantly access their builds from TestFlight after it finishes processing. */ -export async function ensureTestFlightGroupExistsAsync(app: App): Promise { - if (process.env.EAS_NO_AUTO_TESTFLIGHT_SETUP) { - Log.debug('EAS_NO_AUTO_TESTFLIGHT_SETUP is set, skipping TestFlight setup'); - return; - } - +export async function ensureTestFlightGroupExistsAsync( + app: App, + { nonInteractive = false }: { nonInteractive?: boolean } = {} +): Promise { const groups = await app.getBetaGroupsAsync({ query: { includes: ['betaTesters'], @@ -33,19 +31,22 @@ export async function ensureTestFlightGroupExistsAsync(app: App): Promise const group = await ensureInternalGroupAsync({ app, groups, + nonInteractive, }); const users = await User.getAsync(app.context); const admins = users.filter(user => user.attributes.roles?.includes(UserRole.ADMIN)); - await addAllUsersToInternalGroupAsync(group, admins); + await addAllUsersToInternalGroupAsync(group, admins, app); } async function ensureInternalGroupAsync({ groups, app, + nonInteractive, }: { groups: BetaGroup[]; app: App; + nonInteractive: boolean; }): Promise { let betaGroup = groups.find(group => group.attributes.name === AUTO_GROUP_NAME); if (!betaGroup) { @@ -88,6 +89,13 @@ async function ensureInternalGroupAsync({ // `hasAccessToAllBuilds` is a newer feature that allows the group to automatically have access to all builds. This cannot be patched so we need to recreate the group. if (!betaGroup.attributes.hasAccessToAllBuilds) { + if (nonInteractive) { + // Deleting a group is destructive, so it needs explicit confirmation. + Log.warn( + `TestFlight group "${AUTO_GROUP_NAME}" does not have automatic access to new builds. Re-run in interactive mode to regenerate it, or recreate it in App Store Connect.` + ); + return betaGroup; + } if ( await confirmAsync({ message: 'Regenerate internal TestFlight group to allow automatic access to all builds?', @@ -101,6 +109,7 @@ async function ensureInternalGroupAsync({ includes: ['betaTesters'], }, }), + nonInteractive, }); } } @@ -108,7 +117,11 @@ async function ensureInternalGroupAsync({ return betaGroup; } -async function addAllUsersToInternalGroupAsync(group: BetaGroup, users: User[]): Promise { +async function addAllUsersToInternalGroupAsync( + group: BetaGroup, + users: User[], + app: App +): Promise { let emails = users .filter(user => user.attributes.email) .map(user => ({ @@ -162,7 +175,7 @@ async function addAllUsersToInternalGroupAsync(group: BetaGroup, users: User[]): }); if (!success) { - const groupUrl = await getTestFlightGroupUrlAsync(group); + const groupUrl = await getTestFlightGroupUrlAsync(group, app); Log.error( `Unable to add all admins to TestFlight group "${ @@ -181,12 +194,12 @@ async function addAllUsersToInternalGroupAsync(group: BetaGroup, users: User[]): } } -async function getTestFlightGroupUrlAsync(group: BetaGroup): Promise { +async function getTestFlightGroupUrlAsync(group: BetaGroup, app: App): Promise { if (group.context.providerId) { try { const session = await Session.getSessionForProviderIdAsync(group.context.providerId); - return `https://appstoreconnect.apple.com/teams/${session.provider.publicProviderId}/apps/6741088859/testflight/groups/${group.id}`; + return `https://appstoreconnect.apple.com/teams/${session.provider.publicProviderId}/apps/${app.id}/testflight/groups/${group.id}`; } catch (error) { // Avoid crashing if we can't get the session. Log.debug('Failed to get session for provider ID', error); diff --git a/packages/eas-cli/src/submit/context.ts b/packages/eas-cli/src/submit/context.ts index ddcc358a88..81fb6278ca 100644 --- a/packages/eas-cli/src/submit/context.ts +++ b/packages/eas-cli/src/submit/context.ts @@ -21,6 +21,7 @@ export interface SubmissionContext { analyticsEventProperties: AnalyticsEventProperties; exp: ExpoConfig; nonInteractive: boolean; + autoTestFlightSetup: boolean; isVerboseFastlaneEnabled: boolean; groups: T extends Platform.IOS ? string[] : undefined; platform: T; @@ -49,6 +50,7 @@ export async function createSubmissionContextAsync(params: { credentialsCtx?: CredentialsContext; env?: Env; nonInteractive: boolean; + autoTestFlightSetup?: boolean; isVerboseFastlaneEnabled: boolean; groups: string[] | undefined; platform: T; @@ -116,6 +118,7 @@ export async function createSubmissionContextAsync(params: { return { ...rest, + autoTestFlightSetup: params.autoTestFlightSetup ?? true, accountName: account.name, credentialsCtx, groups, diff --git a/packages/eas-cli/src/submit/ios/AppProduce.ts b/packages/eas-cli/src/submit/ios/AppProduce.ts index 5173812021..1d1d0265d7 100644 --- a/packages/eas-cli/src/submit/ios/AppProduce.ts +++ b/packages/eas-cli/src/submit/ios/AppProduce.ts @@ -94,7 +94,7 @@ async function createAppStoreConnectAppAsync( }); try { - await ensureTestFlightGroupExistsAsync(app); + await ensureTestFlightGroupExistsAsync(app, { nonInteractive: ctx.nonInteractive }); } catch (error: any) { // This process is not critical to the app submission so we shouldn't let it fail the entire process. Log.error( diff --git a/packages/eas-cli/src/submit/ios/IosSubmitCommand.ts b/packages/eas-cli/src/submit/ios/IosSubmitCommand.ts index b346ea1ee0..99481ab54d 100644 --- a/packages/eas-cli/src/submit/ios/IosSubmitCommand.ts +++ b/packages/eas-cli/src/submit/ios/IosSubmitCommand.ts @@ -11,6 +11,7 @@ import { } from './AppSpecificPasswordSource'; import { AscApiKeySource, AscApiKeySourceType } from './AscApiKeySource'; import IosSubmitter, { IosSubmissionOptions } from './IosSubmitter'; +import { ensureTestFlightSetupForExistingAppAsync } from './ensureTestFlightSetup'; import { MissingCredentialsError } from '../../credentials/errors'; import Log, { learnMore } from '../../log'; import { ArchiveSource, ArchiveSourceType, getArchiveAsync } from '../ArchiveSource'; @@ -171,6 +172,7 @@ export default class IosSubmitCommand { private async resolveAscAppIdentifierAsync(): Promise> { const { ascAppId } = this.ctx.profile; if (ascAppId) { + await ensureTestFlightSetupForExistingAppAsync(this.ctx, ascAppId); return result(ascAppId); } else if (this.ctx.nonInteractive) { return result( diff --git a/packages/eas-cli/src/submit/ios/__tests__/IosSubmitCommand-test.ts b/packages/eas-cli/src/submit/ios/__tests__/IosSubmitCommand-test.ts index 04e996f503..ae3ce36062 100644 --- a/packages/eas-cli/src/submit/ios/__tests__/IosSubmitCommand-test.ts +++ b/packages/eas-cli/src/submit/ios/__tests__/IosSubmitCommand-test.ts @@ -23,6 +23,7 @@ import { import { refreshContextSubmitProfileAsync } from '../../commons'; import { SubmissionContext, createSubmissionContextAsync } from '../../context'; import IosSubmitCommand from '../IosSubmitCommand'; +import { ensureTestFlightSetupForExistingAppAsync } from '../ensureTestFlightSetup'; jest.mock('fs'); jest.mock('../../../ora'); @@ -52,6 +53,9 @@ jest.mock('../../commons', () => { refreshContextSubmitProfileAsync: jest.fn(), }; }); +jest.mock('../ensureTestFlightSetup', () => ({ + ensureTestFlightSetupForExistingAppAsync: jest.fn(), +})); const vcsClient = resolveVcsClient(); @@ -203,6 +207,11 @@ describe(IosSubmitCommand, () => { submittedBuildId: undefined, }); + expect(ensureTestFlightSetupForExistingAppAsync).toHaveBeenCalledWith( + expect.anything(), + '12345678' + ); + delete process.env.EXPO_APPLE_APP_SPECIFIC_PASSWORD; }); describe('build selected from EAS', () => { diff --git a/packages/eas-cli/src/submit/ios/__tests__/ensureTestFlightSetup-test.ts b/packages/eas-cli/src/submit/ios/__tests__/ensureTestFlightSetup-test.ts new file mode 100644 index 0000000000..cdf7d87e54 --- /dev/null +++ b/packages/eas-cli/src/submit/ios/__tests__/ensureTestFlightSetup-test.ts @@ -0,0 +1,153 @@ +import { App } from '@expo/apple-utils'; +import { Platform } from '@expo/eas-build-job'; + +import { resolveAscApiKeyForAppCredentialsAsync } from '../../../credentials/ios/actions/AscApiKeyUtils'; +import { AuthenticationMode } from '../../../credentials/ios/appstore/authenticateTypes'; +import { ensureTestFlightGroupExistsAsync } from '../../../credentials/ios/appstore/ensureTestFlightGroup'; +import { hasAscEnvVars } from '../../../credentials/ios/appstore/resolveCredentials'; +import { SubmissionContext } from '../../context'; +import { ensureTestFlightSetupForExistingAppAsync } from '../ensureTestFlightSetup'; + +jest.mock('@expo/apple-utils', () => ({ + ...jest.requireActual('@expo/apple-utils'), + App: { infoAsync: jest.fn() }, +})); +jest.mock('../../../credentials/ios/actions/AscApiKeyUtils', () => ({ + resolveAscApiKeyForAppCredentialsAsync: jest.fn(), +})); +jest.mock('../../../credentials/ios/appstore/ensureTestFlightGroup', () => ({ + ensureTestFlightGroupExistsAsync: jest.fn(), +})); +jest.mock('../../../credentials/ios/appstore/resolveCredentials', () => ({ + ...jest.requireActual('../../../credentials/ios/appstore/resolveCredentials'), + hasAscEnvVars: jest.fn(), +})); + +function createContext({ + nonInteractive = false, + autoTestFlightSetup = true, +}: { + nonInteractive?: boolean; + autoTestFlightSetup?: boolean; +}): { + ctx: SubmissionContext; + ensureAuthenticatedAsync: jest.Mock; +} { + const authCtx = { + team: { id: 'team-id' }, + ascApiKey: { keyP8: 'key', keyId: 'key-id', issuerId: 'issuer-id' }, + authState: { context: {} }, + }; + const appStore: any = { authCtx: undefined }; + const ensureAuthenticatedAsync = jest.fn(async () => { + appStore.authCtx = authCtx; + return authCtx; + }); + appStore.ensureAuthenticatedAsync = ensureAuthenticatedAsync; + + return { + ctx: { + nonInteractive, + autoTestFlightSetup, + applicationIdentifierOverride: 'com.example.app', + profile: {}, + user: { accounts: [{ name: 'account' }] }, + accountName: 'account', + projectName: 'project', + credentialsCtx: { appStore }, + graphqlClient: {}, + } as SubmissionContext, + ensureAuthenticatedAsync, + }; +} + +describe(ensureTestFlightSetupForExistingAppAsync, () => { + beforeEach(() => { + delete process.env.EXPO_ASC_API_KEY_PATH; + delete process.env.EXPO_ASC_KEY_ID; + delete process.env.EXPO_ASC_ISSUER_ID; + delete process.env.EXPO_APPLE_TEAM_ID; + jest.mocked(hasAscEnvVars).mockReset().mockReturnValue(false); + jest.mocked(resolveAscApiKeyForAppCredentialsAsync).mockReset().mockResolvedValue(null); + jest + .mocked(App.infoAsync) + .mockReset() + .mockResolvedValue({ id: '12345678' } as App); + jest.mocked(ensureTestFlightGroupExistsAsync).mockReset(); + }); + + it('sets up TestFlight in non-interactive mode when stored credentials are complete', async () => { + jest.mocked(resolveAscApiKeyForAppCredentialsAsync).mockResolvedValue({ + ascApiKey: { keyP8: 'key', keyId: 'key-id', issuerId: 'issuer-id' }, + teamId: 'team-id', + teamName: 'Team', + }); + const { ctx, ensureAuthenticatedAsync } = createContext({ nonInteractive: true }); + + await ensureTestFlightSetupForExistingAppAsync(ctx, '12345678'); + + expect(ensureAuthenticatedAsync).toHaveBeenCalledWith({ + mode: AuthenticationMode.API_KEY, + ascApiKey: { keyP8: 'key', keyId: 'key-id', issuerId: 'issuer-id' }, + teamId: 'team-id', + teamName: 'Team', + teamType: expect.any(String), + }); + expect(ensureTestFlightGroupExistsAsync).toHaveBeenCalledWith(expect.anything(), { + nonInteractive: true, + }); + }); + + it('sets up TestFlight in non-interactive mode when environment credentials are complete', async () => { + jest.mocked(hasAscEnvVars).mockReturnValue(true); + process.env.EXPO_ASC_API_KEY_PATH = '/path/to/key.p8'; + process.env.EXPO_ASC_KEY_ID = 'key-id'; + process.env.EXPO_ASC_ISSUER_ID = 'issuer-id'; + process.env.EXPO_APPLE_TEAM_ID = 'team-id'; + const { ctx, ensureAuthenticatedAsync } = createContext({ nonInteractive: true }); + + await ensureTestFlightSetupForExistingAppAsync(ctx, '12345678'); + + expect(ensureAuthenticatedAsync).toHaveBeenCalledWith({ + mode: AuthenticationMode.API_KEY, + teamId: 'team-id', + teamType: expect.any(String), + }); + expect(ensureTestFlightGroupExistsAsync).toHaveBeenCalledWith(expect.anything(), { + nonInteractive: true, + }); + }); + + it('skips setup when environment credentials are incomplete', async () => { + jest.mocked(hasAscEnvVars).mockReturnValue(true); + process.env.EXPO_ASC_KEY_ID = 'key-id'; + const { ctx, ensureAuthenticatedAsync } = createContext({ nonInteractive: true }); + + await ensureTestFlightSetupForExistingAppAsync(ctx, '12345678'); + + expect(ensureAuthenticatedAsync).not.toHaveBeenCalled(); + expect(resolveAscApiKeyForAppCredentialsAsync).not.toHaveBeenCalled(); + expect(ensureTestFlightGroupExistsAsync).not.toHaveBeenCalled(); + }); + + it('skips setup when stored credentials have no Apple Team ID', async () => { + jest.mocked(resolveAscApiKeyForAppCredentialsAsync).mockResolvedValue({ + ascApiKey: { keyP8: 'key', keyId: 'key-id', issuerId: 'issuer-id' }, + }); + const { ctx, ensureAuthenticatedAsync } = createContext({ nonInteractive: true }); + + await ensureTestFlightSetupForExistingAppAsync(ctx, '12345678'); + + expect(ensureAuthenticatedAsync).not.toHaveBeenCalled(); + expect(ensureTestFlightGroupExistsAsync).not.toHaveBeenCalled(); + }); + + it('skips setup when automatic setup is disabled', async () => { + const { ctx, ensureAuthenticatedAsync } = createContext({ autoTestFlightSetup: false }); + + await ensureTestFlightSetupForExistingAppAsync(ctx, '12345678'); + + expect(ensureAuthenticatedAsync).not.toHaveBeenCalled(); + expect(resolveAscApiKeyForAppCredentialsAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eas-cli/src/submit/ios/ensureTestFlightSetup.ts b/packages/eas-cli/src/submit/ios/ensureTestFlightSetup.ts new file mode 100644 index 0000000000..3234fd94b5 --- /dev/null +++ b/packages/eas-cli/src/submit/ios/ensureTestFlightSetup.ts @@ -0,0 +1,100 @@ +import { App } from '@expo/apple-utils'; +import { Platform } from '@expo/eas-build-job'; +import nullthrows from 'nullthrows'; + +import { + AppleTeamType, + AuthenticationMode, +} from '../../credentials/ios/appstore/authenticateTypes'; +import { getRequestContext } from '../../credentials/ios/appstore/authenticate'; +import { ensureTestFlightGroupExistsAsync } from '../../credentials/ios/appstore/ensureTestFlightGroup'; +import { + hasAscEnvVars, + resolveAppleTeamTypeFromEnvironment, +} from '../../credentials/ios/appstore/resolveCredentials'; +import { resolveAscApiKeyForAppCredentialsAsync } from '../../credentials/ios/actions/AscApiKeyUtils'; +import Log from '../../log'; +import { getBundleIdentifierAsync } from '../../project/ios/bundleIdentifier'; +import { SubmissionContext } from '../context'; + +/** + * Best-effort TestFlight internal group setup for an App Store Connect app + * that already exists with `ascAppId` provided. + */ +export async function ensureTestFlightSetupForExistingAppAsync( + ctx: SubmissionContext, + ascAppIdentifier: string +): Promise { + if (!ctx.autoTestFlightSetup) { + return; + } + + try { + const bundleIdentifier = + ctx.applicationIdentifierOverride ?? + ctx.profile.bundleIdentifier ?? + (await getBundleIdentifierAsync(ctx.projectDir, ctx.exp, ctx.vcsClient)); + + const appLookupParams = { + account: nullthrows( + ctx.user.accounts.find(a => a.name === ctx.accountName), + `You do not have access to account: ${ctx.accountName}` + ), + projectName: ctx.projectName, + bundleIdentifier, + }; + + if (!ctx.credentialsCtx.appStore.authCtx) { + const teamType = + resolveAppleTeamTypeFromEnvironment() ?? AppleTeamType.COMPANY_OR_ORGANIZATION; + if (hasAscEnvVars()) { + const teamId = process.env.EXPO_APPLE_TEAM_ID; + if ( + !process.env.EXPO_ASC_API_KEY_PATH || + !process.env.EXPO_ASC_KEY_ID || + !process.env.EXPO_ASC_ISSUER_ID || + !teamId + ) { + Log.log('App Store Connect credentials are incomplete, skipping TestFlight setup'); + return; + } + await ctx.credentialsCtx.appStore.ensureAuthenticatedAsync({ + mode: AuthenticationMode.API_KEY, + teamId, + teamType, + }); + } else { + const resolvedKey = await resolveAscApiKeyForAppCredentialsAsync({ + graphqlClient: ctx.graphqlClient, + app: appLookupParams, + }); + const teamId = resolvedKey?.teamId ?? process.env.EXPO_APPLE_TEAM_ID; + if (!resolvedKey || !teamId) { + Log.log('No complete App Store Connect credentials, skipping TestFlight setup'); + return; + } + Log.log('Using App Store Connect API Key from EAS credentials service.'); + await ctx.credentialsCtx.appStore.ensureAuthenticatedAsync({ + mode: AuthenticationMode.API_KEY, + ascApiKey: resolvedKey.ascApiKey, + teamId, + teamName: resolvedKey.teamName, + teamType, + }); + } + } + + const authCtx = ctx.credentialsCtx.appStore.authCtx; + if (!authCtx) { + Log.debug('No App Store Connect API key available, skipping TestFlight setup'); + return; + } + + const app = await App.infoAsync(getRequestContext(authCtx), { id: ascAppIdentifier }); + await ensureTestFlightGroupExistsAsync(app, { nonInteractive: ctx.nonInteractive }); + } catch (error: any) { + // Group setup is a convenience on top of the submission and must never + // block it. + Log.warn('Skipping TestFlight group setup:', error); + } +}