From d67300c6a27a41567d8c855651854eae46ddf7e8 Mon Sep 17 00:00:00 2001 From: Serhii Lizunov <34252204+LizunovSergey@users.noreply.github.com> Date: Sat, 19 Sep 2026 13:58:33 +0200 Subject: [PATCH 1/2] [eas-cli] Say why a declined Apple login is required after all Declining the optional Apple sign-in prints "If any of the next steps will require Apple account access we will ask you again about it", but the next step that needs it does not ask: setting up a distribution certificate lists them from the developer portal, which forces authentication, so the user lands on an Apple ID prompt with no explanation and reads the decline as ignored. Keep the behaviour and name the reason once, immediately before the forced login. --- .../src/credentials/__tests__/context-test.ts | 86 +++++++++++++++++++ packages/eas-cli/src/credentials/context.ts | 10 +++ .../credentials/ios/appstore/AppStoreApi.ts | 7 ++ 3 files changed, 103 insertions(+) create mode 100644 packages/eas-cli/src/credentials/__tests__/context-test.ts diff --git a/packages/eas-cli/src/credentials/__tests__/context-test.ts b/packages/eas-cli/src/credentials/__tests__/context-test.ts new file mode 100644 index 0000000000..e4891cc0f3 --- /dev/null +++ b/packages/eas-cli/src/credentials/__tests__/context-test.ts @@ -0,0 +1,86 @@ +import { CredentialsContext } from '../context'; +import { authenticateAsync } from '../ios/appstore/authenticate'; +import Log from '../../log'; +import { confirmAsync } from '../../prompts'; + +jest.mock('../../prompts'); +jest.mock('../../log'); +jest.mock('../ios/appstore/authenticate'); + +function createContext(): CredentialsContext { + return new CredentialsContext({ + projectInfo: null, + nonInteractive: false, + projectDir: '.', + user: { __typename: 'User', username: 'quin', accounts: [] } as any, + graphqlClient: {} as any, + analytics: {} as any, + vcsClient: {} as any, + }); +} + +describe(CredentialsContext.prototype.bestEffortAppStoreAuthenticateAsync, () => { + beforeEach(() => { + jest.resetAllMocks(); + jest.mocked(authenticateAsync).mockResolvedValue({ team: { id: 'TEAM' } } as any); + }); + + it('authenticates without further explanation when the offer is accepted', async () => { + jest.mocked(confirmAsync).mockResolvedValueOnce(true); + const ctx = createContext(); + + await ctx.bestEffortAppStoreAuthenticateAsync(); + + expect(authenticateAsync).toHaveBeenCalledTimes(1); + expect(Log.warn).not.toHaveBeenCalled(); + }); + + it('says why a later step needs the login that was declined', async () => { + // Repros expo/eas-cli#4422: declining prints "we will ask you again about it", and the next + // step that needs Apple access then goes straight to the Apple ID prompt with no reason + // given, which reads as the decline having been ignored. + jest.mocked(confirmAsync).mockResolvedValueOnce(false); + const ctx = createContext(); + + await ctx.bestEffortAppStoreAuthenticateAsync(); + expect(authenticateAsync).not.toHaveBeenCalled(); + expect(Log.warn).not.toHaveBeenCalled(); + + // A later step that cannot run without Apple access, e.g. listing distribution certificates. + await ctx.appStore.ensureAuthenticatedAsync(); + + expect(Log.warn).toHaveBeenCalledWith( + expect.stringContaining('the login you skipped is needed after all') + ); + expect(authenticateAsync).toHaveBeenCalledTimes(1); + }); + + it('explains once, not before every later call', async () => { + jest.mocked(confirmAsync).mockResolvedValueOnce(false); + const ctx = createContext(); + await ctx.bestEffortAppStoreAuthenticateAsync(); + + await ctx.appStore.ensureAuthenticatedAsync(); + ctx.appStore.authCtx = undefined; // a later step re-authenticates, e.g. after a mode switch + await ctx.appStore.ensureAuthenticatedAsync(); + + expect(Log.warn).toHaveBeenCalledTimes(1); + }); + + it('does not prompt or explain in non-interactive mode', async () => { + const ctx = new CredentialsContext({ + projectInfo: null, + nonInteractive: true, + projectDir: '.', + user: { __typename: 'User', username: 'quin', accounts: [] } as any, + graphqlClient: {} as any, + analytics: {} as any, + vcsClient: {} as any, + }); + + await ctx.bestEffortAppStoreAuthenticateAsync(); + + expect(confirmAsync).not.toHaveBeenCalled(); + expect(Log.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eas-cli/src/credentials/context.ts b/packages/eas-cli/src/credentials/context.ts index 561bd319b9..76bd55949e 100644 --- a/packages/eas-cli/src/credentials/context.ts +++ b/packages/eas-cli/src/credentials/context.ts @@ -141,6 +141,16 @@ export class CredentialsContext { 'No problem! ๐Ÿ‘Œ If any of the next steps will require Apple account access we will ask you again about it.' ) ); + // That promise is only half kept: a step that cannot run without Apple access does not ask + // again, it goes straight to the Apple ID prompt, which reads as the decline having been + // ignored (expo/eas-cli#4422). Name the reason when that happens. + this.appStore.onBeforeForcedAuthentication = () => { + this.appStore.onBeforeForcedAuthentication = undefined; + Log.newLine(); + Log.warn( + 'This step cannot be completed without Apple Developer access, so the login you skipped is needed after all.' + ); + }; } this.shouldAskAuthenticateAppStore = false; } diff --git a/packages/eas-cli/src/credentials/ios/appstore/AppStoreApi.ts b/packages/eas-cli/src/credentials/ios/appstore/AppStoreApi.ts index 2314198555..0c59fd0cad 100644 --- a/packages/eas-cli/src/credentials/ios/appstore/AppStoreApi.ts +++ b/packages/eas-cli/src/credentials/ios/appstore/AppStoreApi.ts @@ -52,6 +52,12 @@ import Log from '../../../log'; export default class AppStoreApi { public authCtx?: AuthCtx; public defaultAuthenticationMode: AuthenticationMode; + /** + * Invoked once, immediately before authentication is forced on a caller that did not ask for + * it. Set by {@link CredentialsContext.bestEffortAppStoreAuthenticateAsync} when the optional + * offer was declined, so the login that follows can say why it is required. + */ + public onBeforeForcedAuthentication?: () => void; constructor() { this.defaultAuthenticationMode = hasAscEnvVars() @@ -75,6 +81,7 @@ export default class AppStoreApi { public async ensureAuthenticatedAsync(options?: AuthenticateOptions): Promise { if (!this.authCtx) { + this.onBeforeForcedAuthentication?.(); const mode = options?.mode ?? this.defaultAuthenticationMode; this.authCtx = await authenticateAsync({ mode, ...options }); } From 7240acd152b481698c96620741683a5225bfaab0 Mon Sep 17 00:00:00 2001 From: Serhii Lizunov <34252204+LizunovSergey@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:04:29 +0200 Subject: [PATCH 2/2] [eas-cli] Add changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53b0001220..4df8a044fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ This is the log of notable changes to EAS CLI and related packages. - [build-tools] Allow expo.test subdomains to open local and staging simulator previews. ([#4480](https://github.com/expo/eas-cli/pull/4480) by [@hirbod](https://github.com/hirbod)) - [build-tools] Retry App Store Connect connection failures and safely recover interrupted status reads and upload commits. ([#4520](https://github.com/expo/eas-cli/pull/4520) by [@sjchmiela](https://github.com/sjchmiela)) +- [eas-cli] Say why Apple Developer access is required after all when the optional Apple sign-in was declined, instead of prompting for an Apple ID with no explanation. ([#4444](https://github.com/expo/eas-cli/pull/4444) by [@LizunovSergey](https://github.com/LizunovSergey)) ### ๐Ÿงน Chores