Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ This is the log of notable changes to EAS CLI and related packages.

### 🎉 New features

- [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))
- [eas-cli] Add `eas submit:list`, `eas submit:view`, `eas submit:retry`, `eas submit:cancel`, and `eas submit:status` commands; list and view include the runtime version and fingerprint of the submitted build, and status shows the live App Store version and TestFlight build states cross-referenced with EAS submissions (Google Play app status is not available through EAS yet). ([#4134](https://github.com/expo/eas-cli/pull/4134) by [@brentvatne](https://github.com/brentvatne))
- [eas-build-job] Add optional `ssh` field on build/job payloads. ([#4083](https://github.com/expo/eas-cli/pull/4083) by [@gwdp](https://github.com/gwdp))
- [eas-build-job] Add an `SSH_SESSION` build phase for upcoming worker SSH support. ([#4029](https://github.com/expo/eas-cli/pull/4029) by [@gwdp](https://github.com/gwdp))
Expand All @@ -19,6 +20,7 @@ This is the log of notable changes to EAS CLI and related packages.

### 🐛 Bug fixes

- [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))
- [eas-cli] clean up error handling in local builds. ([#4105](https://github.com/expo/eas-cli/pull/4105) by [@douglowder](https://github.com/douglowder))
- [build-tools] Install ffmpeg when it is missing so Argent screen recording works in EAS Simulator sessions. ([#4110](https://github.com/expo/eas-cli/pull/4110) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Stop simulator job runs when `eas simulator:start` is canceled before the session is ready. ([#4113](https://github.com/expo/eas-cli/pull/4113) by [@sjchmiela](https://github.com/sjchmiela))
Expand Down
59 changes: 3 additions & 56 deletions packages/eas-cli/src/commands/go.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -60,62 +61,8 @@ export async function detectProjectSdkVersionAsync(
}
}

const TESTFLIGHT_GROUP_NAME = 'Team (Expo)';

async function setupTestFlightAsync(ascApp: App): Promise<void> {
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 */
Expand Down
Original file line number Diff line number Diff line change
@@ -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<BetaGroup>[];
createdGroup?: Partial<BetaGroup>;
}): 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<BetaGroup> {
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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
export async function ensureTestFlightGroupExistsAsync(
app: App,
{ nonInteractive = false }: { nonInteractive?: boolean } = {}
): Promise<void> {
if (process.env.EAS_NO_AUTO_TESTFLIGHT_SETUP) {
Log.debug('EAS_NO_AUTO_TESTFLIGHT_SETUP is set, skipping TestFlight setup');
return;
Expand All @@ -33,19 +36,22 @@ export async function ensureTestFlightGroupExistsAsync(app: App): Promise<void>
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<BetaGroup> {
let betaGroup = groups.find(group => group.attributes.name === AUTO_GROUP_NAME);
if (!betaGroup) {
Expand Down Expand Up @@ -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?',
Expand All @@ -101,14 +114,19 @@ async function ensureInternalGroupAsync({
includes: ['betaTesters'],
},
}),
nonInteractive,
});
}
}

return betaGroup;
}

async function addAllUsersToInternalGroupAsync(group: BetaGroup, users: User[]): Promise<void> {
async function addAllUsersToInternalGroupAsync(
group: BetaGroup,
users: User[],
app: App
): Promise<void> {
let emails = users
.filter(user => user.attributes.email)
.map(user => ({
Expand Down Expand Up @@ -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 "${
Expand All @@ -181,12 +199,12 @@ async function addAllUsersToInternalGroupAsync(group: BetaGroup, users: User[]):
}
}

async function getTestFlightGroupUrlAsync(group: BetaGroup): Promise<string | null> {
async function getTestFlightGroupUrlAsync(group: BetaGroup, app: App): Promise<string | null> {
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);
Expand Down
2 changes: 1 addition & 1 deletion packages/eas-cli/src/submit/ios/AppProduce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions packages/eas-cli/src/submit/ios/IosSubmitCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -171,6 +172,7 @@ export default class IosSubmitCommand {
private async resolveAscAppIdentifierAsync(): Promise<Result<string>> {
const { ascAppId } = this.ctx.profile;
if (ascAppId) {
await ensureTestFlightSetupForExistingAppAsync(this.ctx, ascAppId);
return result(ascAppId);
} else if (this.ctx.nonInteractive) {
return result(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -52,6 +53,9 @@ jest.mock('../../commons', () => {
refreshContextSubmitProfileAsync: jest.fn(),
};
});
jest.mock('../ensureTestFlightSetup', () => ({
ensureTestFlightSetupForExistingAppAsync: jest.fn(),
}));

const vcsClient = resolveVcsClient();

Expand Down Expand Up @@ -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', () => {
Expand Down
Loading
Loading