diff --git a/CHANGELOG.md b/CHANGELOG.md index c9f5a034ad..0df45f6b8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,13 @@ This is the log of notable changes to EAS CLI and related packages. - [eas-cli] Make `eas simulator` the canonical command and keep `eas simulator:start` as an alias. ([#4112](https://github.com/expo/eas-cli/pull/4112) by [@szdziedzic](https://github.com/szdziedzic)) - [eas-cli] Validate local composite functions referenced from workflow job hooks during `eas workflow:validate`. ([#4064](https://github.com/expo/eas-cli/pull/4064) by [@sswrk](https://github.com/sswrk)) - [eas-cli] Add `eas sim` as a shortcut for `eas simulator` commands, e.g. `eas sim:list` runs `eas simulator:list`. ([#4150](https://github.com/expo/eas-cli/pull/4150) by [@szdziedzic](https://github.com/szdziedzic)) +- [eas-cli] Set up the internal TestFlight group and invite admin testers when submitting with an existing `ascAppId`, using non-interactive App Store Connect API key auth when available. Previously the automatic TestFlight setup only ran when the CLI created the App Store Connect app itself, so apps created on the App Store Connect website required manual tester configuration before anyone could install builds. ([#4136](https://github.com/expo/eas-cli/pull/4136) by [@tchayen](https://github.com/tchayen)) ### ๐Ÿ› Bug fixes - [eas-cli] Point accounts without EAS Simulator access at the waitlist: `eas simulator:availability` includes the link (and a `waitlistUrl` field in `--json`), and `eas simulator` now fails early with the same message instead of a generic permission error. ([#4151](https://github.com/expo/eas-cli/pull/4151) by [@szdziedzic](https://github.com/szdziedzic)) - [build-tools] Revert "Pin the default `agent-device` version for remote sessions" now that `agent-device` 0.20.5 fixes the broken release. ([#4144](https://github.com/expo/eas-cli/pull/4144) by [@gwdp](https://github.com/gwdp)) +- [eas-cli] 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/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..da819b7644 --- /dev/null +++ b/packages/eas-cli/src/credentials/ios/appstore/__tests__/ensureTestFlightGroup-test.ts @@ -0,0 +1,115 @@ +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(); + delete process.env.EAS_NO_AUTO_TESTFLIGHT_SETUP; +}); + +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(); + }); + + it('skips setup entirely when EAS_NO_AUTO_TESTFLIGHT_SETUP is set', async () => { + process.env.EAS_NO_AUTO_TESTFLIGHT_SETUP = '1'; + const app = mockApp({ groups: [] }); + + await ensureTestFlightGroupExistsAsync(app, { nonInteractive: true }); + + expect(app.getBetaGroupsAsync).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..b17b692f8a 100644 --- a/packages/eas-cli/src/credentials/ios/appstore/ensureTestFlightGroup.ts +++ b/packages/eas-cli/src/credentials/ios/appstore/ensureTestFlightGroup.ts @@ -12,7 +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 { +export async function ensureTestFlightGroupExistsAsync( + app: App, + { nonInteractive = false }: { nonInteractive?: boolean } = {} +): Promise { if (process.env.EAS_NO_AUTO_TESTFLIGHT_SETUP) { Log.debug('EAS_NO_AUTO_TESTFLIGHT_SETUP is set, skipping TestFlight setup'); return; @@ -33,19 +36,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 +94,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 +114,7 @@ async function ensureInternalGroupAsync({ includes: ['betaTesters'], }, }), + nonInteractive, }); } } @@ -108,7 +122,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 +180,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 +199,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/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/ensureTestFlightSetup.ts b/packages/eas-cli/src/submit/ios/ensureTestFlightSetup.ts new file mode 100644 index 0000000000..b8abe2a9fe --- /dev/null +++ b/packages/eas-cli/src/submit/ios/ensureTestFlightSetup.ts @@ -0,0 +1,69 @@ +import { App } from '@expo/apple-utils'; +import { Platform } from '@expo/eas-build-job'; +import nullthrows from 'nullthrows'; + +import { AppleTeamType } from '../../credentials/ios/appstore/authenticateTypes'; +import { getRequestContext } from '../../credentials/ios/appstore/authenticate'; +import { ensureTestFlightGroupExistsAsync } from '../../credentials/ios/appstore/ensureTestFlightGroup'; +import { resolveAppleTeamTypeFromEnvironment } from '../../credentials/ios/appstore/resolveCredentials'; +import { tryAuthenticateAppStoreWithEasAscApiKeyAsync } 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 (`ascAppId` provided in the submit profile). Without + * this, the automatic group creation only ever runs on the interactive path + * that creates the ASC app, so apps created in the App Store Connect website + * are never set up and builds sit in TestFlight with no one able to install + * them. + * + * Authentication is strictly non-interactive: an ASC API key from the + * environment or the EAS credentials service. When neither is available the + * setup is skipped silently โ€” it must never add prompts or failures to + * `eas submit`. + */ +export async function ensureTestFlightSetupForExistingAppAsync( + ctx: SubmissionContext, + ascAppIdentifier: string +): Promise { + if (process.env.EAS_NO_AUTO_TESTFLIGHT_SETUP) { + Log.debug('EAS_NO_AUTO_TESTFLIGHT_SETUP is set, skipping TestFlight setup'); + 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, + }; + + const authenticated = await tryAuthenticateAppStoreWithEasAscApiKeyAsync( + ctx.credentialsCtx, + appLookupParams, + resolveAppleTeamTypeFromEnvironment() ?? AppleTeamType.COMPANY_OR_ORGANIZATION + ); + const authCtx = ctx.credentialsCtx.appStore.authCtx; + if (!authenticated || !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.debug('Skipping TestFlight group setup:', error); + } +}