diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f9c50bf2a..d14a543946 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This is the log of notable changes to EAS CLI and related packages. - [build-tools] Support individual (issuer-less) App Store Connect API keys in the `eas/upload_to_asc` step. ([#4247](https://github.com/expo/eas-cli/pull/4247) by [@szymonswierk](https://github.com/szymonswierk)) - [eas-cli] Accept individual (issuer-less) App Store Connect API keys for submissions, TestFlight setup, and metadata; block them for provisioning operations with an actionable error. ([#4249](https://github.com/expo/eas-cli/pull/4249) by [@szymonswierk](https://github.com/szymonswierk)) +- [eas-cli] Ask for the App Store Connect API key type (team or individual) in the submission key prompt and skip the Issuer ID prompt for individual keys. ([#4250](https://github.com/expo/eas-cli/pull/4250) by [@szymonswierk](https://github.com/szymonswierk)) ### 🐛 Bug fixes diff --git a/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts b/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts index 96b3c5b9ce..543eac3a7e 100644 --- a/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts +++ b/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts @@ -34,9 +34,27 @@ export enum AppStoreApiKeyPurpose { ASC_APP_CONNECTION = 'EAS Connect', } -export async function promptForAscApiKeyPathAsync(ctx: CredentialsContext): Promise { +export async function promptForAscApiKeyPathAsync( + ctx: CredentialsContext, + purpose: AppStoreApiKeyPurpose +): Promise { + // Individual keys are valid only as submission keys. Every other purpose + // requires a team key, so the key type question is not asked there. + const isIndividualKey = + purpose === AppStoreApiKeyPurpose.SUBMISSION_SERVICE && + !ctx.nonInteractive && + (await promptForAscApiKeyTypeIsIndividualAsync()); + const { keyId, keyP8Path } = await promptForKeyP8AndIdAsync(); + if (isIndividualKey) { + Log.log( + 'Individual API keys can be used for submissions, TestFlight setup, and metadata only. ' + + 'They cannot manage certificates or provisioning profiles.' + ); + return { keyId, keyP8Path }; + } + const bestEffortIssuerId = await getBestEffortIssuerIdAsync(ctx, keyId); if (bestEffortIssuerId) { Log.log(`Detected Issuer ID: ${bestEffortIssuerId}`); @@ -46,6 +64,13 @@ export async function promptForAscApiKeyPathAsync(ctx: CredentialsContext): Prom return { keyId, issuerId, keyP8Path }; } +async function promptForAscApiKeyTypeIsIndividualAsync(): Promise { + return await selectAsync('Which type of App Store Connect API key do you want to use?', [ + { title: 'Team key (recommended)', value: false }, + { title: 'Individual key (submissions only)', value: true }, + ]); +} + export async function promptForIssuerIdAsync(): Promise { Log.log(chalk.bold('An App Store Connect Issuer ID is required')); Log.log( @@ -79,7 +104,7 @@ export async function provideOrGenerateAscApiKeyAsync( return await generateAscApiKeyAsync(ctx, purpose); } - const userProvided = await promptForAscApiKeyAsync(ctx); + const userProvided = await promptForAscApiKeyAsync(ctx, purpose); if (!userProvided) { return await generateAscApiKeyAsync(ctx, purpose); } @@ -133,12 +158,15 @@ export function getAscApiKeyName(purpose: AppStoreApiKeyPurpose): string { return nameParts.join(' '); } -async function promptForAscApiKeyAsync(ctx: CredentialsContext): Promise { +async function promptForAscApiKeyAsync( + ctx: CredentialsContext, + purpose: AppStoreApiKeyPurpose +): Promise { const shouldAutoGenerateCredentials = await shouldAutoGenerateCredentialsAsync(ascApiKeyIdSchema); if (shouldAutoGenerateCredentials) { return null; } - const ascApiKeyPath = await promptForAscApiKeyPathAsync(ctx); + const ascApiKeyPath = await promptForAscApiKeyPathAsync(ctx, purpose); const { keyP8Path, keyId, issuerId } = ascApiKeyPath; return { keyP8: await fs.readFile(keyP8Path, 'utf-8'), keyId, issuerId }; } diff --git a/packages/eas-cli/src/credentials/ios/actions/__tests__/AscApiKeyUtils-test.ts b/packages/eas-cli/src/credentials/ios/actions/__tests__/AscApiKeyUtils-test.ts index 4c6a6c257e..b082e4d808 100644 --- a/packages/eas-cli/src/credentials/ios/actions/__tests__/AscApiKeyUtils-test.ts +++ b/packages/eas-cli/src/credentials/ios/actions/__tests__/AscApiKeyUtils-test.ts @@ -64,6 +64,7 @@ describe(getAscApiKeyName, () => { describe(promptForAscApiKeyPathAsync, () => { it('prompts for keyId, keyP8Path and issuerId when user is not authenticated to Apple', async () => { + jest.mocked(selectAsync).mockResolvedValueOnce(false); // team key jest.mocked(promptAsync).mockImplementationOnce(async () => ({ keyP8Path: '/asc-api-key.p8', })); @@ -81,16 +82,21 @@ describe(promptForAscApiKeyPathAsync, () => { authCtx: null, }, }); - const ascApiKeyPath = await promptForAscApiKeyPathAsync(ctx); + const ascApiKeyPath = await promptForAscApiKeyPathAsync( + ctx, + AppStoreApiKeyPurpose.SUBMISSION_SERVICE + ); expect(ascApiKeyPath).toEqual({ keyId: 'test-key-id', issuerId: 'test-issuer-id', keyP8Path: '/asc-api-key.p8', }); + expect(selectAsync).toHaveBeenCalledTimes(1); // key type expect(promptAsync).toHaveBeenCalledTimes(1); // keyP8Path expect(getCredentialsFromUserAsync).toHaveBeenCalledTimes(2); // keyId, issuerId }); it('prompts for keyId, keyP8Path and detects issuerId when user is authenticated to Apple', async () => { + jest.mocked(selectAsync).mockResolvedValueOnce(false); // team key jest.mocked(promptAsync).mockImplementationOnce(async () => ({ keyP8Path: '/asc-api-key.p8', })); @@ -106,7 +112,10 @@ describe(promptForAscApiKeyPathAsync, () => { getAscApiKeyAsync: jest.fn(() => testAscApiKey), }, }); - const ascApiKeyPath = await promptForAscApiKeyPathAsync(ctx); + const ascApiKeyPath = await promptForAscApiKeyPathAsync( + ctx, + AppStoreApiKeyPurpose.SUBMISSION_SERVICE + ); expect(ascApiKeyPath).toEqual({ keyId: 'test-key-id', issuerId: 'test-issuer-id-from-apple', @@ -116,6 +125,60 @@ describe(promptForAscApiKeyPathAsync, () => { expect(getCredentialsFromUserAsync).toHaveBeenCalledTimes(1); // keyId expect(jest.mocked(ctx.appStore.getAscApiKeyAsync).mock.calls.length).toBe(1); // issuerId }); + it('skips the issuer prompt for an individual key in the submission flow', async () => { + jest.mocked(selectAsync).mockResolvedValueOnce(true); // individual key + jest.mocked(promptAsync).mockImplementationOnce(async () => ({ + keyP8Path: '/asc-api-key.p8', + })); + jest.mocked(getCredentialsFromUserAsync).mockImplementation(async () => ({ + keyId: 'test-key-id', + })); + const getAscApiKeyAsync = jest.fn(() => testAscApiKey); + const ctx = createCtxMock({ + nonInteractive: false, + appStore: { + ...getAppstoreMock(), + authCtx: testAuthCtx, + getAscApiKeyAsync, + }, + }); + const ascApiKeyPath = await promptForAscApiKeyPathAsync( + ctx, + AppStoreApiKeyPurpose.SUBMISSION_SERVICE + ); + expect(ascApiKeyPath).toEqual({ + keyId: 'test-key-id', + keyP8Path: '/asc-api-key.p8', + }); + expect(getCredentialsFromUserAsync).toHaveBeenCalledTimes(1); // keyId only + expect(getAscApiKeyAsync).not.toHaveBeenCalled(); // no issuer detection + }); + it('does not ask the key type outside the submission flow', async () => { + jest.mocked(promptAsync).mockImplementationOnce(async () => ({ + keyP8Path: '/asc-api-key.p8', + })); + jest.mocked(getCredentialsFromUserAsync).mockImplementation(async () => ({ + keyId: 'test-key-id', + issuerId: 'test-issuer-id', + })); + const ctx = createCtxMock({ + nonInteractive: false, + appStore: { + ...getAppstoreMock(), + authCtx: null, + }, + }); + const ascApiKeyPath = await promptForAscApiKeyPathAsync( + ctx, + AppStoreApiKeyPurpose.ASC_APP_CONNECTION + ); + expect(ascApiKeyPath).toEqual({ + keyId: 'test-key-id', + issuerId: 'test-issuer-id', + keyP8Path: '/asc-api-key.p8', + }); + expect(selectAsync).not.toHaveBeenCalled(); + }); }); describe(provideOrGenerateAscApiKeyAsync, () => { diff --git a/packages/eas-cli/src/credentials/ios/appstore/resolveCredentials.ts b/packages/eas-cli/src/credentials/ios/appstore/resolveCredentials.ts index ffd9adae33..80f6b679ee 100644 --- a/packages/eas-cli/src/credentials/ios/appstore/resolveCredentials.ts +++ b/packages/eas-cli/src/credentials/ios/appstore/resolveCredentials.ts @@ -48,6 +48,12 @@ export async function resolveAscApiKeyAsync( (!!ascApiKey?.keyId || !!process.env.EXPO_ASC_KEY_ID) && !ascApiKey?.issuerId && !process.env.EXPO_ASC_ISSUER_ID; + if (keyProvidedWithoutIssuer) { + Log.log( + 'No Issuer ID provided; treating the App Store Connect API key as an individual key. ' + + 'Individual keys can be used for submissions, TestFlight setup, and metadata only.' + ); + } const passedIssuerId = keyProvidedWithoutIssuer ? undefined : await getAscIssuerIdFromEnvironmentOrOptionsAsync(ascApiKey); diff --git a/packages/eas-cli/src/submit/ios/AscApiKeySource.ts b/packages/eas-cli/src/submit/ios/AscApiKeySource.ts index 49a646f3ca..087bafa2f9 100644 --- a/packages/eas-cli/src/submit/ios/AscApiKeySource.ts +++ b/packages/eas-cli/src/submit/ios/AscApiKeySource.ts @@ -189,7 +189,10 @@ async function handlePromptSourceAsync( ctx: SubmissionContext, _source: AscApiKeyPromptSource ): Promise { - const ascApiKeyPath = await promptForAscApiKeyPathAsync(ctx.credentialsCtx); + const ascApiKeyPath = await promptForAscApiKeyPathAsync( + ctx.credentialsCtx, + AppStoreApiKeyPurpose.SUBMISSION_SERVICE + ); return await getAscApiKeyPathAsync(ctx, { sourceType: AscApiKeySourceType.path, path: ascApiKeyPath,