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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features

- [eas-cli] Add `--refresh-ad-hoc-provisioning-profile` flag to refresh managed ad-hoc provisioning profiles from App Store Connect before gathering build credentials in non-interactive mode. ([#3716](https://github.com/expo/eas-cli/pull/3716) by [@sswrk](https://github.com/sswrk))
- [eas-cli] Add `--refresh-distribution-certificate` flag to validate and refresh the distribution certificate from App Store Connect before gathering build credentials in non-interactive mode. ([#3739](https://github.com/expo/eas-cli/pull/3739) by [@sswrk](https://github.com/sswrk))

### 🐛 Bug fixes

Expand Down
4 changes: 4 additions & 0 deletions packages/eas-cli/src/build/createContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export async function createBuildContextAsync<T extends Platform>({
buildLoggerLevel,
freezeCredentials,
refreshAdHocProvisioningProfile: refreshAdHocProvisioningProfileFlag,
refreshDistributionCertificate: refreshDistributionCertificateFlag,
isVerboseLoggingEnabled,
whatToTest,
env,
Expand All @@ -67,6 +68,7 @@ export async function createBuildContextAsync<T extends Platform>({
buildLoggerLevel?: LoggerLevel;
freezeCredentials: boolean;
refreshAdHocProvisioningProfile?: boolean;
refreshDistributionCertificate?: boolean;
isVerboseLoggingEnabled: boolean;
whatToTest?: string;
env: Record<string, string>;
Expand All @@ -93,6 +95,7 @@ export async function createBuildContextAsync<T extends Platform>({
const requiredPackageManager = resolvePackageManager(projectDir);

const refreshAdHocProvisioningProfile = refreshAdHocProvisioningProfileFlag ?? false;
const refreshDistributionCertificate = refreshDistributionCertificateFlag ?? false;

const credentialsCtx = new CredentialsContext({
projectInfo: { exp, projectId },
Expand All @@ -106,6 +109,7 @@ export async function createBuildContextAsync<T extends Platform>({
vcsClient,
freezeCredentials,
refreshAdHocProvisioningProfile,
refreshDistributionCertificate,
});

const devClientProperties = getDevClientEventProperties({
Expand Down
33 changes: 33 additions & 0 deletions packages/eas-cli/src/build/ios/__tests__/credentials-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,37 @@ describe(ensureIosCredentialsAsync, () => {

await expect(ensureIosCredentialsAsync(buildCtx, [])).resolves.toBeUndefined();
});

it('errors when distribution certificate refresh is enabled with local credentials source', async () => {
const buildCtx = {
buildProfile: {
credentialsSource: CredentialsSource.LOCAL,
simulator: false,
withoutCredentials: false,
},
credentialsCtx: {
refreshDistributionCertificate: true,
},
} as BuildContext<Platform.IOS>;

await expect(ensureIosCredentialsAsync(buildCtx, [])).rejects.toThrow(
'--refresh-distribution-certificate cannot be used with credentialsSource "local". Use remote credentials or omit the flag.'
);
});

it('does not reject distribution certificate refresh when credentials source is remote', async () => {
const buildCtx = {
buildProfile: {
credentialsSource: CredentialsSource.REMOTE,
simulator: true,
withoutCredentials: false,
distribution: 'store',
},
credentialsCtx: {
refreshDistributionCertificate: true,
},
} as BuildContext<Platform.IOS>;

await expect(ensureIosCredentialsAsync(buildCtx, [])).resolves.toBeUndefined();
});
});
5 changes: 5 additions & 0 deletions packages/eas-cli/src/build/ios/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export async function ensureIosCredentialsAsync(
'--refresh-ad-hoc-provisioning-profile cannot be used with credentialsSource "local". Use remote credentials or omit the flag.'
);
}
if (buildCtx.credentialsCtx.refreshDistributionCertificate && credentialsSource === 'local') {
throw new Error(
'--refresh-distribution-certificate cannot be used with credentialsSource "local". Use remote credentials or omit the flag.'
);
}

const provider = new IosCredentialsProvider(buildCtx.credentialsCtx, {
app: await getAppFromContextAsync(buildCtx.credentialsCtx),
Expand Down
2 changes: 2 additions & 0 deletions packages/eas-cli/src/build/runBuildAndSubmit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export interface BuildFlags {
buildLoggerLevel?: LoggerLevel;
freezeCredentials: boolean;
refreshAdHocProvisioningProfile?: boolean;
refreshDistributionCertificate?: boolean;
isVerboseLoggingEnabled?: boolean;
whatToTest?: string;
simulator?: SimulatorRunTarget;
Expand Down Expand Up @@ -415,6 +416,7 @@ async function prepareAndStartBuildAsync({
buildLoggerLevel: flags.buildLoggerLevel ?? (Log.isDebug ? LoggerLevel.DEBUG : undefined),
freezeCredentials: flags.freezeCredentials,
refreshAdHocProvisioningProfile: flags.refreshAdHocProvisioningProfile,
refreshDistributionCertificate: flags.refreshDistributionCertificate,
isVerboseLoggingEnabled: flags.isVerboseLoggingEnabled ?? false,
whatToTest: flags.whatToTest,
env,
Expand Down
1 change: 1 addition & 0 deletions packages/eas-cli/src/build/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,5 @@ export interface BuildFlags {
buildLoggerLevel?: LoggerLevel;
freezeCredentials: boolean;
refreshAdHocProvisioningProfile?: boolean;
refreshDistributionCertificate?: boolean;
}
21 changes: 21 additions & 0 deletions packages/eas-cli/src/commands/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ interface RawBuildFlags {
'build-logger-level'?: LoggerLevel;
'freeze-credentials': boolean;
'refresh-ad-hoc-provisioning-profile': boolean;
'refresh-distribution-certificate': boolean;
'verbose-logs'?: boolean;
'what-to-test'?: string;
}
Expand Down Expand Up @@ -127,6 +128,11 @@ export default class Build extends EasCommand {
description:
'Refresh managed ad-hoc provisioning profiles from App Store Connect before gathering build credentials',
}),
'refresh-distribution-certificate': Flags.boolean({
default: false,
description:
'Validate and refresh the distribution certificate from App Store Connect before gathering build credentials',
}),
'verbose-logs': Flags.boolean({
default: false,
description: 'Use verbose logs for the build process',
Expand Down Expand Up @@ -208,6 +214,20 @@ export default class Build extends EasCommand {
);
}
}
if (flags['refresh-distribution-certificate']) {
if (!nonInteractive) {
Errors.error(
'--refresh-distribution-certificate can only be used in non-interactive mode.',
{ exit: 1 }
);
}
if (flags['freeze-credentials']) {
Errors.error(
'Cannot use --refresh-distribution-certificate with --freeze-credentials.',
{ exit: 1 }
);
}
}
if (!flags.local && flags.output) {
Errors.error('--output is allowed only for local builds', { exit: 1 });
}
Expand Down Expand Up @@ -270,6 +290,7 @@ export default class Build extends EasCommand {
buildLoggerLevel: flags['build-logger-level'],
freezeCredentials: flags['freeze-credentials'],
refreshAdHocProvisioningProfile: flags['refresh-ad-hoc-provisioning-profile'],
refreshDistributionCertificate: flags['refresh-distribution-certificate'],
isVerboseLoggingEnabled: flags['verbose-logs'],
whatToTest: flags['what-to-test'],
};
Expand Down
3 changes: 3 additions & 0 deletions packages/eas-cli/src/credentials/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export class CredentialsContext {
public readonly autoAcceptCredentialReuse: boolean;
public readonly freezeCredentials: boolean = false;
public readonly refreshAdHocProvisioningProfile: boolean = false;
public readonly refreshDistributionCertificate: boolean = false;
public readonly projectDir: string;
public readonly user: Actor;
public readonly graphqlClient: ExpoGraphqlClient;
Expand All @@ -54,6 +55,7 @@ export class CredentialsContext {
freezeCredentials?: boolean;
autoAcceptCredentialReuse?: boolean;
refreshAdHocProvisioningProfile?: boolean;
refreshDistributionCertificate?: boolean;
env?: Env;
}
) {
Expand All @@ -68,6 +70,7 @@ export class CredentialsContext {
this.projectInfo = options.projectInfo;
this.freezeCredentials = options.freezeCredentials ?? false;
this.refreshAdHocProvisioningProfile = options.refreshAdHocProvisioningProfile ?? false;
this.refreshDistributionCertificate = options.refreshDistributionCertificate ?? false;
this.usesBroadcastPushNotifications =
options.projectInfo?.exp.ios?.usesBroadcastPushNotifications ?? false;
}
Expand Down
33 changes: 33 additions & 0 deletions packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import { UserRole } from '@expo/apple-utils';

import { formatAppleTeam } from './AppleTeamFormatting';
import { AccountFragment, AppStoreConnectApiKeyFragment } from '../../../graphql/generated';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppStoreConnectApiKeyQuery } from '../../../graphql/queries/AppStoreConnectApiKeyQuery';
import Log, { learnMore } from '../../../log';
import { getAscApiKeyForAppSubmissionsAsync } from '../api/GraphqlClient';
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
import { confirmAsync, promptAsync, selectAsync } from '../../../prompts';
import { fromNow } from '../../../utils/date';
import { CredentialsContext } from '../../context';
Expand Down Expand Up @@ -277,3 +281,32 @@ export function formatAscApiKey(ascApiKey: AppStoreConnectApiKeyFragment): strin
line += chalk.gray(`\n Updated: ${fromNow(new Date(updatedAt))} ago`);
return line;
}

export async function resolveAscApiKeyForAppCredentialsAsync({
graphqlClient,
app,
}: {
graphqlClient: ExpoGraphqlClient;
app: AppLookupParams;
}): Promise<{
ascApiKey: MinimalAscApiKey;
teamId?: string;
teamName?: string;
} | null> {
const ascKeyFragment = await getAscApiKeyForAppSubmissionsAsync(graphqlClient, app);
if (!ascKeyFragment) {
return null;
}

Log.log('Using App Store Connect API Key from EAS credentials service.');
const fullKey = await AppStoreConnectApiKeyQuery.getByIdAsync(graphqlClient, ascKeyFragment.id);
return {
ascApiKey: {
keyP8: fullKey.keyP8,
keyId: fullKey.keyIdentifier,
issuerId: fullKey.issuerIdentifier,
},
teamId: ascKeyFragment.appleTeam?.appleTeamIdentifier,
teamName: ascKeyFragment.appleTeam?.appleTeamName ?? undefined,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import chalk from 'chalk';
import nullthrows from 'nullthrows';

import { resolveAppleTeamIfAuthenticatedAsync } from './AppleTeamUtils';
import { resolveAscApiKeyForAppCredentialsAsync } from './AscApiKeyUtils';
import { assignBuildCredentialsAsync, getBuildCredentialsAsync } from './BuildCredentialsUtils';
import {
chooseDevicesAsync,
Expand All @@ -22,8 +23,6 @@ import {
IosAppBuildCredentialsFragment,
IosDistributionType,
} from '../../../graphql/generated';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppStoreConnectApiKeyQuery } from '../../../graphql/queries/AppStoreConnectApiKeyQuery';
import Log from '../../../log';
import { getApplePlatformFromTarget } from '../../../project/ios/target';
import {
Expand All @@ -35,13 +34,11 @@ import {
import differenceBy from '../../../utils/expodash/differenceBy';
import { CredentialsContext } from '../../context';
import { MissingCredentialsNonInteractiveError } from '../../errors';
import { getAscApiKeyForAppSubmissionsAsync } from '../api/GraphqlClient';
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
import { AppleTeamType, AuthenticationMode } from '../appstore/authenticateTypes';
import { ProvisioningProfile } from '../appstore/Credentials.types';
import { ApplePlatform } from '../appstore/constants';
import { hasAscEnvVars } from '../appstore/resolveCredentials';
import { MinimalAscApiKey } from '../credentials';
import { Target } from '../types';
import { validateProvisioningProfileAsync } from '../validators/validateProvisioningProfile';

Expand Down Expand Up @@ -407,6 +404,9 @@ export class SetUpAdhocProvisioningProfile {
ctx: CredentialsContext,
app: AppLookupParams
): Promise<void> {
if (ctx.appStore.authCtx) {
return;
}
if (hasAscEnvVars()) {
await ctx.appStore.ensureAuthenticatedAsync({ mode: AuthenticationMode.API_KEY });
return;
Expand Down Expand Up @@ -436,35 +436,6 @@ export class SetUpAdhocProvisioningProfile {
}
}

async function resolveAscApiKeyForAppCredentialsAsync({
graphqlClient,
app,
}: {
graphqlClient: ExpoGraphqlClient;
app: AppLookupParams;
}): Promise<{
ascApiKey: MinimalAscApiKey;
teamId?: string;
teamName?: string;
} | null> {
const ascKeyFragment = await getAscApiKeyForAppSubmissionsAsync(graphqlClient, app);
if (!ascKeyFragment) {
return null;
}

Log.log('Using App Store Connect API Key from EAS credentials service.');
const fullKey = await AppStoreConnectApiKeyQuery.getByIdAsync(graphqlClient, ascKeyFragment.id);
return {
ascApiKey: {
keyP8: fullKey.keyP8,
keyId: fullKey.keyIdentifier,
issuerId: fullKey.issuerIdentifier,
},
teamId: ascKeyFragment.appleTeam?.appleTeamIdentifier,
teamName: ascKeyFragment.appleTeam?.appleTeamName ?? undefined,
};
}

export function doUDIDsMatch(udidsA: string[], udidsB: string[]): boolean {
const setA = new Set(udidsA);
const setB = new Set(udidsB);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import assert from 'assert';

import { resolveAppleTeamIfAuthenticatedAsync } from './AppleTeamUtils';
import { resolveAscApiKeyForAppCredentialsAsync } from './AscApiKeyUtils';
import { CreateDistributionCertificate } from './CreateDistributionCertificate';
import { formatDistributionCertificate } from './DistributionCertificateUtils';
import {
Expand All @@ -16,6 +17,8 @@ import { MissingCredentialsNonInteractiveError } from '../../errors';
import { AppleDistributionCertificateMutationResult } from '../api/graphql/mutations/AppleDistributionCertificateMutation';
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
import { getValidCertSerialNumbers } from '../appstore/CredentialsUtils';
import { AppleTeamType, AuthenticationMode } from '../appstore/authenticateTypes';
import { hasAscEnvVars } from '../appstore/resolveCredentials';
import { AppleTeamMissingError } from '../errors';

export class SetUpDistributionCertificate {
Expand Down Expand Up @@ -50,13 +53,64 @@ export class SetUpDistributionCertificate {
}
}

private async ensureAppStoreAuthenticatedForDistCertRefreshAsync(
ctx: CredentialsContext
): Promise<void> {
if (ctx.appStore.authCtx) {
return;
}
if (hasAscEnvVars()) {
await ctx.appStore.ensureAuthenticatedAsync({ mode: AuthenticationMode.API_KEY });
return;
}

const resolvedKey = await resolveAscApiKeyForAppCredentialsAsync({
graphqlClient: ctx.graphqlClient,
app: this.app,
});
if (!resolvedKey) {
throw new Error(
'No App Store Connect API Key found for distribution certificate refresh. In non-interactive mode, provide one via:\n' +
' - Environment variables: EXPO_ASC_API_KEY_PATH, EXPO_ASC_KEY_ID, EXPO_ASC_ISSUER_ID\n' +
' - EAS credentials service: configure an App Store Connect API Key for submissions on this app'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ugh it's still weird to me we're saying to use key for submissions to generate certificates

);
}
await ctx.appStore.ensureAuthenticatedAsync({
mode: AuthenticationMode.API_KEY,
ascApiKey: resolvedKey.ascApiKey,
teamId: resolvedKey.teamId,
teamName: resolvedKey.teamName,
teamType: AppleTeamType.COMPANY_OR_ORGANIZATION,
});
}

private async runNonInteractiveAsync(
_ctx: CredentialsContext,
ctx: CredentialsContext,
currentCertificate: AppleDistributionCertificateFragment | null
): Promise<AppleDistributionCertificateFragment> {
// TODO: implement validation
if (ctx.refreshDistributionCertificate) {
await this.ensureAppStoreAuthenticatedForDistCertRefreshAsync(ctx);

if (await this.isCurrentCertificateValidAsync(ctx, currentCertificate)) {
assert(currentCertificate, 'currentCertificate is defined here');
Comment on lines +94 to +95
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (await this.isCurrentCertificateValidAsync(ctx, currentCertificate)) {
assert(currentCertificate, 'currentCertificate is defined here');
if (currentCertificate && await this.isCurrentCertificateValidAsync(ctx, currentCertificate)) {

this makes more sense to me?

Log.log('Using existing valid distribution certificate.');
return currentCertificate;
}

Log.warn('Current distribution certificate is invalid. Creating a new one...');
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move this log.warn to when we actually create a new cert, since it's possible we could reuse

Suggested change
Log.warn('Current distribution certificate is invalid. Creating a new one...');

const validDistCerts = await this.getValidDistCertsAsync(ctx);
if (validDistCerts.length > 0) {
const cert = validDistCerts[0];
Log.log(`Reusing distribution certificate with serial number ${cert.serialNumber}`);
return cert;
}
return await this.createNewDistCertAsync(ctx);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return await this.createNewDistCertAsync(ctx);
Log.warn('Current distribution certificate is invalid. Creating a new one...');
return await this.createNewDistCertAsync(ctx);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how likely is that we're going to hit

if (ctx.nonInteractive) {
throw new Error(
"Start the CLI without the '--non-interactive' flag to revoke existing certificates."
);
}
often? can we do something about it?

}

Log.addNewLineIfNone();
Log.warn('Distribution Certificate is not validated for non-interactive builds.');
Log.warn(
'Using the existing distribution certificate without validating it against Apple servers. Use --refresh-distribution-certificate to validate and refresh if needed.'
);
if (!currentCertificate) {
throw new MissingCredentialsNonInteractiveError();
}
Expand Down
Loading
Loading