Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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
10 changes: 10 additions & 0 deletions packages/eas-cli/src/commands/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ interface RawCommandFlags {
verbose: boolean;
wait: boolean;
'non-interactive': boolean;
'auto-testflight-setup': boolean;
'verbose-fastlane': boolean;
groups?: string[];
}
Expand All @@ -46,6 +47,7 @@ interface CommandFlags {
verbose: boolean;
wait: boolean;
nonInteractive: boolean;
autoTestFlightSetup: boolean;
isVerboseFastlaneEnabled: boolean;
groups?: string[];
}
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -221,6 +230,7 @@ export default class Submit extends EasCommand {
wait,
profile,
nonInteractive,
autoTestFlightSetup,
whatToTest,
isVerboseFastlaneEnabled,
groups,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<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();
});

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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<void> {
const groups = await app.getBetaGroupsAsync({
query: {
includes: ['betaTesters'],
Expand All @@ -33,19 +31,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 +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?',
Expand All @@ -101,14 +109,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 +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 "${
Expand All @@ -181,12 +194,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
3 changes: 3 additions & 0 deletions packages/eas-cli/src/submit/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface SubmissionContext<T extends Platform> {
analyticsEventProperties: AnalyticsEventProperties;
exp: ExpoConfig;
nonInteractive: boolean;
autoTestFlightSetup: boolean;
isVerboseFastlaneEnabled: boolean;
groups: T extends Platform.IOS ? string[] : undefined;
platform: T;
Expand Down Expand Up @@ -49,6 +50,7 @@ export async function createSubmissionContextAsync<T extends Platform>(params: {
credentialsCtx?: CredentialsContext;
env?: Env;
nonInteractive: boolean;
autoTestFlightSetup?: boolean;
isVerboseFastlaneEnabled: boolean;
groups: string[] | undefined;
platform: T;
Expand Down Expand Up @@ -116,6 +118,7 @@ export async function createSubmissionContextAsync<T extends Platform>(params: {

return {
...rest,
autoTestFlightSetup: params.autoTestFlightSetup ?? true,
accountName: account.name,
credentialsCtx,
groups,
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
Loading
Loading