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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 17 additions & 7 deletions packages/eas-cli/src/credentials/manager/ManageIos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,24 +256,32 @@ 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<IosDistributionTypeGraphql> =>
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;
}

const target = await this.selectTargetAsync(targets);
const appLookupParams = await getAppLookupParamsFromContextAsync(ctx, target);
switch (action) {
case IosActionType.UseExistingDistributionCertificate: {
const distributionType = await resolveDistributionTypeAsync();
const distCert = await selectValidDistributionCertificateAsync(ctx, appLookupParams);
if (!distCert) {
return;
Expand All @@ -288,6 +296,7 @@ export class ManageIos {
return;
}
case IosActionType.CreateDistributionCertificate: {
const distributionType = await resolveDistributionTypeAsync();
const distCert = await new CreateDistributionCertificate(appLookupParams.account).runAsync(
ctx
);
Expand All @@ -306,6 +315,7 @@ export class ManageIos {
return;
}
case IosActionType.RemoveProvisioningProfile: {
const distributionType = await resolveDistributionTypeAsync();
const iosAppCredentials = await ctx.ios.getIosAppCredentialsWithCommonFieldsAsync(
ctx.graphqlClient,
appLookupParams
Expand Down
166 changes: 166 additions & 0 deletions packages/eas-cli/src/credentials/manager/__tests__/ManageIos-test.ts
Original file line number Diff line number Diff line change
@@ -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<Platform.IOS>;

class ManageIosForTesting extends ManageIos {
public async runProjectSpecificActionForTestingAsync(
ctx: CredentialsContext,
app: App,
targets: Target[],
buildProfile: BuildProfile<Platform.IOS>,
action: IosActionType
): Promise<void> {
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<Platform.IOS>;
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();
});
});
Loading