diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a390c6fa1..4c8a4312d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐Ÿ› Bug fixes +- [eas-cli] Fix `eas credentials` failing with "A simulator distribution does not require credentials to be configured." when managing push keys or App Store Connect API keys on a build profile with `ios.simulator: true`. These are app-level credentials and do not depend on the build distribution type, so the distribution type is now only resolved for the actions that actually use it. ([#4183](https://github.com/expo/eas-cli/pull/4183) by [@giaBaoJS](https://github.com/giaBaoJS)) + ### ๐Ÿงน Chores ## [23.2.0](https://github.com/expo/eas-cli/releases/tag/v23.2.0) - 2026-08-31 diff --git a/packages/eas-cli/src/credentials/manager/ManageIos.ts b/packages/eas-cli/src/credentials/manager/ManageIos.ts index 36c1865cb0..dbd4e3999a 100644 --- a/packages/eas-cli/src/credentials/manager/ManageIos.ts +++ b/packages/eas-cli/src/credentials/manager/ManageIos.ts @@ -256,17 +256,24 @@ export class ManageIos { return; } - const distributionType = await new SelectIosDistributionTypeGraphqlFromBuildProfile( - buildProfile - ).runAsync(ctx); + // Resolving the distribution type throws for build profiles with `ios.simulator: true`. + // Resolve it lazily, so that only the actions which actually consume a distribution type + // are gated by that check, and app-level credentials (push keys, App Store Connect API keys) + // can still be managed from a simulator build profile. + const resolveDistributionTypeAsync = async (): Promise => + await new SelectIosDistributionTypeGraphqlFromBuildProfile(buildProfile).runAsync(ctx); if (action === IosActionType.SetUpBuildCredentialsFromCredentialsJson) { - await new SetUpBuildCredentialsFromCredentialsJson(app, targets, distributionType).runAsync( - ctx - ); + await new SetUpBuildCredentialsFromCredentialsJson( + app, + targets, + await resolveDistributionTypeAsync() + ).runAsync(ctx); return; } else if (action === IosActionType.UpdateCredentialsJson) { - await new UpdateCredentialsJson(app, targets, distributionType).runAsync(ctx); + await new UpdateCredentialsJson(app, targets, await resolveDistributionTypeAsync()).runAsync( + ctx + ); return; } @@ -274,6 +281,7 @@ export class ManageIos { const appLookupParams = await getAppLookupParamsFromContextAsync(ctx, target); switch (action) { case IosActionType.UseExistingDistributionCertificate: { + const distributionType = await resolveDistributionTypeAsync(); const distCert = await selectValidDistributionCertificateAsync(ctx, appLookupParams); if (!distCert) { return; @@ -288,6 +296,7 @@ export class ManageIos { return; } case IosActionType.CreateDistributionCertificate: { + const distributionType = await resolveDistributionTypeAsync(); const distCert = await new CreateDistributionCertificate(appLookupParams.account).runAsync( ctx ); @@ -306,6 +315,7 @@ export class ManageIos { return; } case IosActionType.RemoveProvisioningProfile: { + const distributionType = await resolveDistributionTypeAsync(); const iosAppCredentials = await ctx.ios.getIosAppCredentialsWithCommonFieldsAsync( ctx.graphqlClient, appLookupParams diff --git a/packages/eas-cli/src/credentials/manager/__tests__/ManageIos-test.ts b/packages/eas-cli/src/credentials/manager/__tests__/ManageIos-test.ts new file mode 100644 index 0000000000..0702c302de --- /dev/null +++ b/packages/eas-cli/src/credentials/manager/__tests__/ManageIos-test.ts @@ -0,0 +1,166 @@ +import { Platform } from '@expo/eas-build-job'; +import { BuildProfile } from '@expo/eas-json'; + +import { Analytics } from '../../../analytics/AnalyticsManager'; +import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { IosDistributionType } from '../../../graphql/generated'; +import { Actor } from '../../../user/User'; +import { Client } from '../../../vcs/vcs'; +import { jester, testSlug } from '../../__tests__/fixtures-constants'; +import { createCtxMock } from '../../__tests__/fixtures-context'; +import { testTargets } from '../../__tests__/fixtures-ios'; +import { CredentialsContext, CredentialsContextProjectInfo } from '../../context'; +import { getAppLookupParamsFromContextAsync } from '../../ios/actions/BuildCredentialsUtils'; +import { SetUpPushKey } from '../../ios/actions/SetUpPushKey'; +import { UpdateCredentialsJson } from '../../ios/actions/UpdateCredentialsJson'; +import { AppLookupParams } from '../../ios/api/graphql/types/AppLookupParams'; +import { App, Target } from '../../ios/types'; +import { IosActionType } from '../Actions'; +import { Action } from '../HelperActions'; +import { ManageIos } from '../ManageIos'; + +jest.mock('../../ios/actions/AscApiKeyUtils', () => ({ + ...jest.requireActual('../../ios/actions/AscApiKeyUtils'), + selectAscApiKeysFromAccountAsync: jest.fn(), +})); +jest.mock('../../ios/actions/AssignAscApiKey'); +jest.mock('../../ios/actions/AssignPushKey'); +jest.mock('../../ios/actions/BuildCredentialsUtils'); +jest.mock('../../ios/actions/CreateAscApiKey'); +jest.mock('../../ios/actions/CreatePushKey'); +jest.mock('../../ios/actions/PushKeyUtils'); +jest.mock('../../ios/actions/SetUpAscApiKey'); +jest.mock('../../ios/actions/SetUpPushKey'); +jest.mock('../../ios/actions/UpdateCredentialsJson'); + +const testIosAppLookupParams: AppLookupParams = { + account: jester.accounts[0], + projectName: testSlug, + bundleIdentifier: testTargets[0].bundleIdentifier, +}; + +const testApp: App = { + account: jester.accounts[0], + projectName: testSlug, +}; + +const simulatorBuildProfile = { + distribution: 'internal', + simulator: true, +} as BuildProfile; + +class ManageIosForTesting extends ManageIos { + public async runProjectSpecificActionForTestingAsync( + ctx: CredentialsContext, + app: App, + targets: Target[], + buildProfile: BuildProfile, + action: IosActionType + ): Promise { + await this.runProjectSpecificActionAsync(ctx, app, targets, buildProfile, action); + } +} + +function createManageIos(): ManageIosForTesting { + return new ManageIosForTesting( + { + projectInfo: {} as CredentialsContextProjectInfo, + actor: {} as Actor, + graphqlClient: {} as ExpoGraphqlClient, + analytics: {} as Analytics, + vcsClient: {} as Client, + getDynamicPrivateProjectConfigAsync: jest.fn().mockResolvedValue({ exp: {}, projectId: '' }), + runAsync: jest.fn(), + } as Action, + '' + ); +} + +describe('runProjectSpecificActionAsync', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(getAppLookupParamsFromContextAsync).mockResolvedValue(testIosAppLookupParams); + }); + + it('sets up a push key for a build profile with a simulator distribution', async () => { + // Push keys are app-level credentials and do not depend on the build distribution type, + // so they must be configurable from a build profile with `ios.simulator: true`. + // See: https://github.com/expo/eas-cli/issues/4109 + jest.mocked(SetUpPushKey.prototype.isPushKeySetupAsync).mockResolvedValue(false); + const ctx = createCtxMock({ nonInteractive: false }); + + await createManageIos().runProjectSpecificActionForTestingAsync( + ctx, + testApp, + [testTargets[0]], + simulatorBuildProfile, + IosActionType.SetUpPushKey + ); + + expect(jest.mocked(SetUpPushKey)).toHaveBeenCalledWith(testIosAppLookupParams); + expect(jest.mocked(SetUpPushKey.prototype.runAsync)).toHaveBeenCalledWith(ctx); + }); + + // Every project-scoped action that never reads a distribution type. Managing these credentials + // must not be blocked by a build profile with `ios.simulator: true`. + it.each([ + ['SetUpPushKey', IosActionType.SetUpPushKey], + ['CreatePushKey', IosActionType.CreatePushKey], + ['UseExistingPushKey', IosActionType.UseExistingPushKey], + ['SetUpAscApiKeyForSubmissions', IosActionType.SetUpAscApiKeyForSubmissions], + ['UseExistingAscApiKeyForSubmissions', IosActionType.UseExistingAscApiKeyForSubmissions], + ['CreateAscApiKeyForSubmissions', IosActionType.CreateAscApiKeyForSubmissions], + ])( + 'does not require a distribution type for %s on a simulator build profile', + async (_name, action) => { + const ctx = createCtxMock({ nonInteractive: false }); + + await expect( + createManageIos().runProjectSpecificActionForTestingAsync( + ctx, + testApp, + [testTargets[0]], + simulatorBuildProfile, + action + ) + ).resolves.not.toThrow(); + } + ); + + it('still resolves the distribution type for actions that need it', async () => { + const buildProfile = { + distribution: 'store', + } as BuildProfile; + const ctx = createCtxMock({ nonInteractive: false }); + const targets = [testTargets[0]]; + + await createManageIos().runProjectSpecificActionForTestingAsync( + ctx, + testApp, + targets, + buildProfile, + IosActionType.UpdateCredentialsJson + ); + + expect(jest.mocked(UpdateCredentialsJson)).toHaveBeenCalledWith( + testApp, + targets, + IosDistributionType.AppStore + ); + }); + + it('still rejects a simulator distribution for actions that need a distribution type', async () => { + const ctx = createCtxMock({ nonInteractive: false }); + + await expect( + createManageIos().runProjectSpecificActionForTestingAsync( + ctx, + testApp, + [testTargets[0]], + simulatorBuildProfile, + IosActionType.UpdateCredentialsJson + ) + ).rejects.toThrow('A simulator distribution does not require credentials to be configured.'); + expect(jest.mocked(UpdateCredentialsJson)).not.toHaveBeenCalled(); + }); +});