Skip to content

Commit

Permalink
feat: add internal TestFlight group to submit (#2839)
Browse files Browse the repository at this point in the history
* feat: add internal TestFlight group to submit

* Update CHANGELOG.md

* fixed transient issue

* Update AppProduce.ts

* refactor

* remove ability to add all users

* Update ensureTestFlightGroup.ts

* Update provisioningProfile.ts

* reduce

* Update target-test.ts
  • Loading branch information
EvanBacon authored Jan 29, 2025
1 parent ab507d0 commit fe37600
Show file tree
Hide file tree
Showing 8 changed files with 219 additions and 35 deletions.
1 change: 1 addition & 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

- Automatically create internal TestFlight group in EAS Submit command. ([#2839](https://github.com/expo/eas-cli/pull/2839) by [@evanbacon](https://github.com/evanbacon))
- Sanitize and generate names for EAS Submit to prevent failures due to invalid characters or taken names. ([#2842](https://github.com/expo/eas-cli/pull/2842) by [@evanbacon](https://github.com/evanbacon))

### 🐛 Bug fixes
Expand Down
2 changes: 1 addition & 1 deletion packages/eas-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
},
"bugs": "https://github.com/expo/eas-cli/issues",
"dependencies": {
"@expo/apple-utils": "2.1.5",
"@expo/apple-utils": "2.1.6",
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "10.0.6",
"@expo/config-plugins": "9.0.12",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ export async function createAppAsync(
}
}

function isAppleError(error: any): error is {
export function isAppleError(error: any): error is {
data: {
errors: {
id: string;
Expand Down
196 changes: 196 additions & 0 deletions packages/eas-cli/src/credentials/ios/appstore/ensureTestFlightGroup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { App, BetaGroup, Session, User, UserRole } from '@expo/apple-utils';

import { isAppleError } from './ensureAppExists';
import Log from '../../../log';
import { ora } from '../../../ora';
import { confirmAsync } from '../../../prompts';

// The name of the internal TestFlight group, this should probably never change.
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> {
const group = await ensureInternalGroupAsync(app);
const users = await User.getAsync(app.context);
const admins = users.filter(user => user.attributes.roles?.includes(UserRole.ADMIN));

await addAllUsersToInternalGroupAsync(group, admins);
}

async function ensureInternalGroupAsync(app: App): Promise<BetaGroup> {
const groups = await app.getBetaGroupsAsync({
query: {
includes: ['betaTesters'],
},
});

let betaGroup = groups.find(group => group.attributes.name === AUTO_GROUP_NAME);
if (!betaGroup) {
const spinner = ora().start('Creating TestFlight group...');

try {
// Apple throw an error if you create the group too quickly after creating the app. We'll retry a few times.
await pollRetryAsync(
async () => {
betaGroup = await app.createBetaGroupAsync({
name: AUTO_GROUP_NAME,
publicLinkEnabled: false,
publicLinkLimitEnabled: false,
isInternalGroup: true,
// Automatically add latest builds to the group without needing to run the command.
hasAccessToAllBuilds: true,
});
},
{
shouldRetry(error) {
if (isAppleError(error)) {
spinner.text = `TestFlight not ready, retrying in 25 seconds...`;

return error.data.errors.some(
error => error.code === 'ENTITY_ERROR.RELATIONSHIP.INVALID'
);
}
return false;
},
}
);
spinner.succeed(`TestFlight group created: ${AUTO_GROUP_NAME}`);
} catch (error: any) {
spinner.fail('Failed to create TestFlight group...');

throw error;
}
}
if (!betaGroup) {
throw new Error('Failed to create internal TestFlight group');
}

// `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 (
await confirmAsync({
message: 'Regenerate internal TestFlight group to allow automatic access to all builds?',
})
) {
await BetaGroup.deleteAsync(app.context, { id: betaGroup.id });
return await ensureInternalGroupAsync(app);
}
}

return betaGroup;
}

async function addAllUsersToInternalGroupAsync(group: BetaGroup, users: User[]): Promise<void> {
let emails = users
.filter(user => user.attributes.email)
.map(user => ({
email: user.attributes.email!,
firstName: user.attributes.firstName ?? '',
lastName: user.attributes.lastName ?? '',
}));

const { betaTesters } = group.attributes;
const existingEmails = betaTesters?.map(tester => tester.attributes.email).filter(Boolean) ?? [];
// Filter out existing beta testers.
if (betaTesters) {
emails = emails.filter(
user => !existingEmails.find(existingEmail => existingEmail === user.email)
);
}

// No new users to add to the internal group.
if (!emails.length) {
// No need to log which users are here on subsequent runs as devs already know the drill at this point.
Log.debug(`All current admins are already added to the group: ${group.attributes.name}`);
return;
}

Log.debug(`Adding ${emails.length} users to internal group: ${group.attributes.name}`);
Log.debug(`Users: ${emails.map(user => user.email).join(', ')}`);

const data = await group.createBulkBetaTesterAssignmentsAsync(emails);

const success = data.attributes.betaTesters.every(tester => {
if (tester.assignmentResult === 'FAILED') {
if (tester.errors && Array.isArray(tester.errors) && tester.errors.length) {
if (
tester.errors.length === 1 &&
tester.errors[0].key === 'Halliday.tester.already.exists'
) {
return true;
}
for (const error of tester.errors) {
Log.error(
`Error adding user ${tester.email} to TestFlight group "${group.attributes.name}": ${error.key}`
);
}
}
return false;
}
if (tester.assignmentResult === 'NOT_QUALIFIED_FOR_INTERNAL_GROUP') {
return false;
}
return true;
});

if (!success) {
const groupUrl = await getTestFlightGroupUrlAsync(group);

Log.error(
`Unable to add all admins to TestFlight group "${
group.attributes.name
}". You can add them manually in App Store Connect. ${groupUrl ?? ''}`
);
} else {
Log.log(
`TestFlight access enabled for: ` +
data.attributes.betaTesters
.map(tester => tester.email)
.filter(Boolean)
.join(', ')
);
// TODO: When we have more TestFlight functionality, we can link to it from here.
}
}

async function getTestFlightGroupUrlAsync(group: BetaGroup): 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}`;
} catch (error) {
// Avoid crashing if we can't get the session.
Log.debug('Failed to get session for provider ID', error);
}
}
return null;
}

async function pollRetryAsync<T>(
fn: () => Promise<T>,
{
shouldRetry,
retries = 10,
// 25 seconds was the minium interval I calculated when measuring against 5 second intervals.
interval = 25000,
}: { shouldRetry?: (error: Error) => boolean; retries?: number; interval?: number } = {}
): Promise<T> {
let lastError: Error | null = null;
for (let i = 0; i < retries; i++) {
try {
return await fn();
} catch (error: any) {
if (shouldRetry && !shouldRetry(error)) {
throw error;
}
lastError = error;
await new Promise(resolve => setTimeout(resolve, interval));
}
}
// eslint-disable-next-line @typescript-eslint/no-throw-literal
throw lastError;
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function resolveProfileType(
return resolveProfileTypeIos(profileClass, isEnterprise);
case ApplePlatform.TV_OS:
return resolveProfileTypeAppleTv(profileClass, isEnterprise);
case ApplePlatform.VISION_OS:
case ApplePlatform.MAC_OS:
throw new Error(`${applePlatform} profiles are not supported`);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/eas-cli/src/project/ios/__tests__/target-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ describe(getApplePlatformFromSdkRoot, () => {
// Update `getApplePlatformFromSdkRoot` to be compatible with new Apple Platforms
test('all enumerations work with the function', () => {
expect(Object.values(ApplePlatform).sort()).toEqual(
[ApplePlatform.IOS, ApplePlatform.TV_OS, ApplePlatform.MAC_OS].sort()
[ApplePlatform.IOS, ApplePlatform.TV_OS, ApplePlatform.MAC_OS, ApplePlatform.VISION_OS].sort()
);
});
test('existing SDKs work with the function', () => {
Expand Down
11 changes: 11 additions & 0 deletions packages/eas-cli/src/submit/ios/AppProduce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ensureAppExistsAsync,
ensureBundleIdExistsWithNameAsync,
} from '../../credentials/ios/appstore/ensureAppExists';
import { ensureTestFlightGroupExistsAsync } from '../../credentials/ios/appstore/ensureTestFlightGroup';
import Log from '../../log';
import { getBundleIdentifierAsync } from '../../project/ios/bundleIdentifier';
import { promptAsync } from '../../prompts';
Expand Down Expand Up @@ -92,6 +93,16 @@ async function createAppStoreConnectAppAsync(
sku,
});

try {
await ensureTestFlightGroupExistsAsync(app);
} catch (error: any) {
// This process is not critical to the app submission so we shouldn't let it fail the entire process.
Log.error(
'Failed to create an internal TestFlight group. This can be done manually in the App Store Connect website.'
);
Log.error(error);
}

return {
ascAppIdentifier: app.id,
};
Expand Down
39 changes: 7 additions & 32 deletions yarn.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit fe37600

Please sign in to comment.