From 3356ab2aa66c966bbc554a8724cf3df784dc1db1 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Tue, 18 Aug 2026 18:27:23 -0700 Subject: [PATCH 01/10] [eas-cli] allow publishing over an in-progress rollout --- CHANGELOG.md | 1 + packages/eas-cli/src/commands/update/index.ts | 21 ++- .../eas-cli/src/commands/update/republish.ts | 12 ++ .../commands/update/roll-back-to-embedded.ts | 10 + .../eas-cli/src/commands/update/rollback.ts | 13 +- packages/eas-cli/src/graphql/generated.ts | 7 + .../update/__tests__/active-rollout-test.ts | 176 ++++++++++++++++++ packages/eas-cli/src/update/active-rollout.ts | 131 +++++++++++++ packages/eas-cli/src/update/republish.ts | 20 +- .../src/update/roll-back-to-embedded.ts | 24 ++- 10 files changed, 409 insertions(+), 6 deletions(-) create mode 100644 packages/eas-cli/src/update/__tests__/active-rollout-test.ts create mode 100644 packages/eas-cli/src/update/active-rollout.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 271ae9a3f6..7a34a4b5e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐ŸŽ‰ New features +- [eas-update] Add `--force-end-active-rollout` to `eas update`, `eas update:republish`, `eas update:roll-back-to-embedded` and `eas update:rollback`, so these commands can publish over a rollout that is in progress instead of being rejected. Without the flag, the commands warn and ask for confirmation first. ([#4233](https://github.com/expo/eas-cli/pull/4233) by [@gwdp](https://github.com/gwdp)) ### ๐Ÿ› Bug fixes ### ๐Ÿงน Chores diff --git a/packages/eas-cli/src/commands/update/index.ts b/packages/eas-cli/src/commands/update/index.ts index 8b960316f5..fefafe4487 100644 --- a/packages/eas-cli/src/commands/update/index.ts +++ b/packages/eas-cli/src/commands/update/index.ts @@ -51,6 +51,7 @@ import { uploadAssetsAsync, } from '../../project/publish'; import { resolveWorkflowPerPlatformAsync } from '../../project/workflow'; +import { resolveUpdateGroupsSupersedingActiveRolloutsAsync } from '../../update/active-rollout'; import { ensureEASUpdateIsConfiguredAsync } from '../../update/configure'; import { UpdatePublishPlatform, @@ -107,6 +108,7 @@ type RawUpdateFlags = { 'private-key-path'?: string; 'emit-metadata': boolean; 'rollout-percentage'?: number; + 'force-end-active-rollout': boolean; 'non-interactive': boolean; json: boolean; environment?: string; @@ -126,6 +128,7 @@ type UpdateFlags = { privateKeyPath?: string; emitMetadata: boolean; rolloutPercentage?: number; + forceEndActiveRollout: boolean; json: boolean; nonInteractive: boolean; environment?: string; @@ -181,6 +184,11 @@ export default class UpdatePublish extends EasCommand { min: 0, max: 100, }), + 'force-end-active-rollout': Flags.boolean({ + description: + 'Skip the confirmation prompt and end an in-progress rollout on the runtime version being published, so this update supersedes it. The update being rolled out is then served to every user until they receive this one.', + default: false, + }), platform: Flags.option({ char: 'p', options: Object.values(RequestedPlatform), // TODO: Add web when it's fully supported @@ -228,6 +236,7 @@ export default class UpdatePublish extends EasCommand { branchName: branchNameArg, emitMetadata, rolloutPercentage, + forceEndActiveRollout, environment: environmentFromFlags, } = this.sanitizeFlags(rawFlags); @@ -585,10 +594,19 @@ export default class UpdatePublish extends EasCommand { }; } ); + const updateGroupsToPublish = await resolveUpdateGroupsSupersedingActiveRolloutsAsync( + graphqlClient, + updateGroups, + { appId: projectId, branchName: branch.name, nonInteractive, forceEndActiveRollout } + ); + let newUpdates: UpdatePublishMutation['updateBranch']['publishUpdateGroups']; const publishSpinner = ora('Publishing...').start(); try { - newUpdates = await PublishMutation.publishUpdateGroupAsync(graphqlClient, updateGroups); + newUpdates = await PublishMutation.publishUpdateGroupAsync( + graphqlClient, + updateGroupsToPublish + ); if (codeSigningInfo) { Log.log('๐Ÿ”’ Signing updates'); @@ -773,6 +791,7 @@ export default class UpdatePublish extends EasCommand { platform: flags.platform, privateKeyPath: flags['private-key-path'], rolloutPercentage: flags['rollout-percentage'], + forceEndActiveRollout: flags['force-end-active-rollout'], nonInteractive, emitMetadata, json, diff --git a/packages/eas-cli/src/commands/update/republish.ts b/packages/eas-cli/src/commands/update/republish.ts index 8e11487833..b79f3d3ba9 100644 --- a/packages/eas-cli/src/commands/update/republish.ts +++ b/packages/eas-cli/src/commands/update/republish.ts @@ -30,6 +30,7 @@ type UpdateRepublishRawFlags = { message?: string; platform: string; 'private-key-path'?: string; + 'force-end-active-rollout': boolean; 'non-interactive': boolean; json?: boolean; 'rollout-percentage'?: number; @@ -44,6 +45,7 @@ type UpdateRepublishFlags = { updateMessage?: string; platform: Platform[]; privateKeyPath?: string; + forceEndActiveRollout: boolean; nonInteractive: boolean; json: boolean; rolloutPercentage?: number; @@ -95,6 +97,11 @@ export default class UpdateRepublish extends EasCommand { min: 0, max: 100, }), + 'force-end-active-rollout': Flags.boolean({ + description: + 'Skip the confirmation prompt and end an in-progress rollout on the runtime version being republished to, so this update supersedes it. The update being rolled out is then served to every user until they receive this one.', + default: false, + }), ...EasNonInteractiveAndJsonFlags, }; @@ -177,6 +184,10 @@ export default class UpdateRepublish extends EasCommand { codeSigningInfo, json: flags.json, rolloutPercentage: flags.rolloutPercentage, + activeRollout: { + forceEndActiveRollout: flags.forceEndActiveRollout, + nonInteractive: flags.nonInteractive, + }, }); } @@ -206,6 +217,7 @@ export default class UpdateRepublish extends EasCommand { updateMessage: rawFlags.message, privateKeyPath, rolloutPercentage: rawFlags['rollout-percentage'], + forceEndActiveRollout: rawFlags['force-end-active-rollout'], json, nonInteractive, }; diff --git a/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts b/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts index 1c88c32c97..462e856568 100644 --- a/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts +++ b/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts @@ -31,6 +31,7 @@ type RawUpdateFlags = { message?: string; platform: string; 'private-key-path'?: string; + 'force-end-active-rollout': boolean; 'non-interactive': boolean; json: boolean; }; @@ -42,6 +43,7 @@ type UpdateFlags = { runtimeVersion?: string; updateMessage?: string; privateKeyPath?: string; + forceEndActiveRollout: boolean; json: boolean; nonInteractive: boolean; }; @@ -80,6 +82,11 @@ export default class UpdateRollBackToEmbedded extends EasCommand { description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory. Only relevant if you are using code signing: https://docs.expo.dev/eas-update/code-signing/`, required: false, }), + 'force-end-active-rollout': Flags.boolean({ + description: + 'Skip the confirmation prompt and end an in-progress rollout on the runtime version being rolled back, so this roll back supersedes it. The update being rolled out is then served to every user until they receive this one.', + default: false, + }), ...EasNonInteractiveAndJsonFlags, }; @@ -98,6 +105,7 @@ export default class UpdateRollBackToEmbedded extends EasCommand { updateMessage: updateMessageArg, runtimeVersion: runtimeVersionArg, privateKeyPath, + forceEndActiveRollout, json: jsonFlag, nonInteractive, branchName: branchNameArg, @@ -194,6 +202,7 @@ export default class UpdateRollBackToEmbedded extends EasCommand { platforms: realizedPlatforms, runtimeVersion: selectedRuntime, json: jsonFlag, + activeRollout: { forceEndActiveRollout, nonInteractive }, }); } @@ -223,6 +232,7 @@ export default class UpdateRollBackToEmbedded extends EasCommand { runtimeVersion, platform: flags.platform as RequestedPlatform, privateKeyPath: flags['private-key-path'], + forceEndActiveRollout: flags['force-end-active-rollout'], nonInteractive, json, }; diff --git a/packages/eas-cli/src/commands/update/rollback.ts b/packages/eas-cli/src/commands/update/rollback.ts index 4ee346892a..d6e157d3fe 100644 --- a/packages/eas-cli/src/commands/update/rollback.ts +++ b/packages/eas-cli/src/commands/update/rollback.ts @@ -51,6 +51,11 @@ export default class UpdateRollback extends EasCommand { description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory. Only relevant if you are using code signing: https://docs.expo.dev/eas-update/code-signing/`, required: false, }), + 'force-end-active-rollout': Flags.boolean({ + description: + 'Skip the confirmation prompt and end an in-progress rollout on the runtime version being rolled back, so this roll back supersedes it. The update being rolled out is then served to every user until they receive this one.', + default: false, + }), ...EasNonInteractiveAndJsonFlags, }; @@ -65,6 +70,9 @@ export default class UpdateRollback extends EasCommand { const groupId = args.groupId; const platform = flags.platform; const messageArg = flags.message; + const forceEndActiveRolloutArg = flags['force-end-active-rollout'] + ? ['--force-end-active-rollout'] + : []; const privateKeyPathArg = flags['private-key-path'] ? ['--private-key-path', flags['private-key-path']] : []; @@ -85,9 +93,9 @@ export default class UpdateRollback extends EasCommand { }); if (choice === 'published') { - await UpdateRepublish.run(privateKeyPathArg); + await UpdateRepublish.run([...privateKeyPathArg, ...forceEndActiveRolloutArg]); } else { - await UpdateRollBackToEmbedded.run(privateKeyPathArg); + await UpdateRollBackToEmbedded.run([...privateKeyPathArg, ...forceEndActiveRolloutArg]); } return; } @@ -108,6 +116,7 @@ export default class UpdateRollback extends EasCommand { '--platform', platform, ...privateKeyPathArg, + ...forceEndActiveRolloutArg, ...(json ? ['--json'] : []), ]; diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index ac2f996704..efff05810c 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -9189,6 +9189,7 @@ export type PublishUpdateGroupInput = { isGitWorkingTreeDirty?: InputMaybe; manifestHostOverride?: InputMaybe; message?: InputMaybe; + previousRolloutUpdateToClobberIdGroup?: InputMaybe; rollBackToEmbeddedInfoGroup?: InputMaybe; rolloutInfoGroup?: InputMaybe; runtimeVersion: Scalars['String']['input']; @@ -11110,6 +11111,12 @@ export type UpdateGroupsConnection = { pageInfo: PageInfo; }; +export type UpdateIdGroup = { + android?: InputMaybe; + ios?: InputMaybe; + web?: InputMaybe; +}; + export type UpdateInfoGroup = { android?: InputMaybe; ios?: InputMaybe; diff --git a/packages/eas-cli/src/update/__tests__/active-rollout-test.ts b/packages/eas-cli/src/update/__tests__/active-rollout-test.ts new file mode 100644 index 0000000000..8f8ca47ebf --- /dev/null +++ b/packages/eas-cli/src/update/__tests__/active-rollout-test.ts @@ -0,0 +1,176 @@ +import { resolveUpdateGroupsSupersedingActiveRolloutsAsync } from '../active-rollout'; +import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; +import { AppPlatform, PublishUpdateGroupInput, UpdateFragment } from '../../graphql/generated'; +import { UpdateQuery } from '../../graphql/queries/UpdateQuery'; +import { confirmAsync } from '../../prompts'; + +jest.mock('../../graphql/queries/UpdateQuery'); +jest.mock('../../prompts'); + +const graphqlClient = {} as ExpoGraphqlClient; + +const rolloutUpdateStub: UpdateFragment = { + id: 'update-rollout', + group: 'group-rollout', + branch: { id: 'branch-1234', name: 'main' }, + message: 'rollout message', + runtime: { id: 'runtime-1234', version: '1.0.0' }, + platform: 'ios', + gitCommitHash: 'commit', + isGitWorkingTreeDirty: false, + manifestFragment: JSON.stringify({ fake: 'manifest' }), + isRollBackToEmbedded: false, + manifestPermalink: 'https://expo.dev/fake/manifest/link', + codeSigningInfo: null, + createdAt: '2022-01-01T12:00:00Z', + rolloutPercentage: 25, +}; + +const manifestStub = { + assets: [], + launchAsset: { + bundleKey: 'bundle', + contentType: 'application/javascript', + fileSHA256: 'sha', + storageKey: 'storage', + }, +}; + +const updateGroupStub: PublishUpdateGroupInput = { + branchId: 'branch-1234', + runtimeVersion: '1.0.0', + rollBackToEmbeddedInfoGroup: { ios: true }, +}; + +const resolveOptions = { appId: 'app-1234', branchName: 'main' }; + +beforeEach(() => { + jest.mocked(UpdateQuery.viewUpdateGroupsOnBranchAsync).mockReset(); + jest.mocked(confirmAsync).mockReset(); +}); + +describe(resolveUpdateGroupsSupersedingActiveRolloutsAsync, () => { + it('leaves update groups untouched when no rollout is in progress', async () => { + jest + .mocked(UpdateQuery.viewUpdateGroupsOnBranchAsync) + .mockResolvedValue([[{ ...rolloutUpdateStub, rolloutPercentage: null }]]); + + const result = await resolveUpdateGroupsSupersedingActiveRolloutsAsync( + graphqlClient, + [updateGroupStub], + { ...resolveOptions, nonInteractive: false, forceEndActiveRollout: false } + ); + + expect(result).toEqual([updateGroupStub]); + expect(confirmAsync).not.toHaveBeenCalled(); + }); + + it('names the rollout to supersede without prompting when the flag is passed', async () => { + jest.mocked(UpdateQuery.viewUpdateGroupsOnBranchAsync).mockResolvedValue([[rolloutUpdateStub]]); + + const result = await resolveUpdateGroupsSupersedingActiveRolloutsAsync( + graphqlClient, + [updateGroupStub], + { ...resolveOptions, nonInteractive: false, forceEndActiveRollout: true } + ); + + expect(result[0].previousRolloutUpdateToClobberIdGroup).toEqual({ ios: 'update-rollout' }); + expect(confirmAsync).not.toHaveBeenCalled(); + }); + + it('names the rollout to supersede once the prompt is confirmed', async () => { + jest.mocked(UpdateQuery.viewUpdateGroupsOnBranchAsync).mockResolvedValue([[rolloutUpdateStub]]); + jest.mocked(confirmAsync).mockResolvedValue(true); + + const result = await resolveUpdateGroupsSupersedingActiveRolloutsAsync( + graphqlClient, + [updateGroupStub], + { ...resolveOptions, nonInteractive: false, forceEndActiveRollout: false } + ); + + expect(result[0].previousRolloutUpdateToClobberIdGroup).toEqual({ ios: 'update-rollout' }); + }); + + it('aborts when the prompt is declined', async () => { + jest.mocked(UpdateQuery.viewUpdateGroupsOnBranchAsync).mockResolvedValue([[rolloutUpdateStub]]); + jest.mocked(confirmAsync).mockResolvedValue(false); + + await expect( + resolveUpdateGroupsSupersedingActiveRolloutsAsync(graphqlClient, [updateGroupStub], { + ...resolveOptions, + nonInteractive: false, + forceEndActiveRollout: false, + }) + ).rejects.toThrow('Aborted.'); + }); + + it('names the rollout for each platform that has one', async () => { + jest + .mocked(UpdateQuery.viewUpdateGroupsOnBranchAsync) + .mockImplementation(async (_client, { filter }) => + filter?.platform === AppPlatform.Ios + ? [[rolloutUpdateStub]] + : [[{ ...rolloutUpdateStub, id: 'update-android', platform: 'android' }]] + ); + + const result = await resolveUpdateGroupsSupersedingActiveRolloutsAsync( + graphqlClient, + [{ ...updateGroupStub, rollBackToEmbeddedInfoGroup: { ios: true, android: true } }], + { ...resolveOptions, nonInteractive: false, forceEndActiveRollout: true } + ); + + expect(result[0].previousRolloutUpdateToClobberIdGroup).toEqual({ + ios: 'update-rollout', + android: 'update-android', + }); + }); + + it('names the rollout only for the update group that has one', async () => { + jest + .mocked(UpdateQuery.viewUpdateGroupsOnBranchAsync) + .mockImplementation(async (_client, { filter }) => + filter?.runtimeVersions?.includes('1.0.0') + ? [[rolloutUpdateStub]] + : [[{ ...rolloutUpdateStub, rolloutPercentage: null }]] + ); + + const result = await resolveUpdateGroupsSupersedingActiveRolloutsAsync( + graphqlClient, + [ + { ...updateGroupStub, runtimeVersion: '2.0.0' }, + { ...updateGroupStub, updateInfoGroup: { ios: manifestStub } }, + ], + { ...resolveOptions, nonInteractive: false, forceEndActiveRollout: true } + ); + + expect(result[0].previousRolloutUpdateToClobberIdGroup).toBeUndefined(); + expect(result[1].previousRolloutUpdateToClobberIdGroup).toEqual({ ios: 'update-rollout' }); + }); + + it('rejects starting a partial rollout over a rollout in progress', async () => { + jest.mocked(UpdateQuery.viewUpdateGroupsOnBranchAsync).mockResolvedValue([[rolloutUpdateStub]]); + + await expect( + resolveUpdateGroupsSupersedingActiveRolloutsAsync(graphqlClient, [updateGroupStub], { + ...resolveOptions, + nonInteractive: false, + forceEndActiveRollout: true, + rolloutPercentage: 10, + }) + ).rejects.toThrow('it would jump to 90% instead of ending'); + expect(confirmAsync).not.toHaveBeenCalled(); + }); + + it('requires the flag in non-interactive mode', async () => { + jest.mocked(UpdateQuery.viewUpdateGroupsOnBranchAsync).mockResolvedValue([[rolloutUpdateStub]]); + + await expect( + resolveUpdateGroupsSupersedingActiveRolloutsAsync(graphqlClient, [updateGroupStub], { + ...resolveOptions, + nonInteractive: true, + forceEndActiveRollout: false, + }) + ).rejects.toThrow('--force-end-active-rollout'); + expect(confirmAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eas-cli/src/update/active-rollout.ts b/packages/eas-cli/src/update/active-rollout.ts new file mode 100644 index 0000000000..832c3125da --- /dev/null +++ b/packages/eas-cli/src/update/active-rollout.ts @@ -0,0 +1,131 @@ +import { Errors } from '@oclif/core'; + +import { UpdatePublishPlatform, updatePublishPlatformToAppPlatform } from './utils'; +import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; +import { PublishUpdateGroupInput, UpdateFragment } from '../graphql/generated'; +import { UpdateQuery } from '../graphql/queries/UpdateQuery'; +import Log from '../log'; +import { confirmAsync } from '../prompts'; + +type ActiveRollout = { platform: UpdatePublishPlatform; update: UpdateFragment }; + +function getPlatformsForUpdateGroup(updateGroup: PublishUpdateGroupInput): UpdatePublishPlatform[] { + const infoGroup = updateGroup.updateInfoGroup ?? updateGroup.rollBackToEmbeddedInfoGroup; + return Object.keys(infoGroup ?? {}).filter( + (platform): platform is UpdatePublishPlatform => platform in updatePublishPlatformToAppPlatform + ); +} + +async function findActiveRolloutUpdateAsync( + graphqlClient: ExpoGraphqlClient, + { + appId, + branchName, + runtimeVersion, + platform, + }: { + appId: string; + branchName: string; + runtimeVersion: string; + platform: UpdatePublishPlatform; + } +): Promise { + const latestUpdateGroups = await UpdateQuery.viewUpdateGroupsOnBranchAsync(graphqlClient, { + appId, + branchName, + limit: 1, + offset: 0, + filter: { + runtimeVersions: [runtimeVersion], + platform: updatePublishPlatformToAppPlatform[platform], + }, + }); + const latestUpdate = latestUpdateGroups?.[0]?.find(update => update.platform === platform); + return latestUpdate?.rolloutPercentage != null ? latestUpdate : null; +} + +export async function resolveUpdateGroupsSupersedingActiveRolloutsAsync( + graphqlClient: ExpoGraphqlClient, + updateGroups: PublishUpdateGroupInput[], + { + appId, + branchName, + nonInteractive, + forceEndActiveRollout, + rolloutPercentage, + }: { + appId: string; + branchName: string; + nonInteractive: boolean; + forceEndActiveRollout: boolean; + rolloutPercentage?: number; + } +): Promise { + const activeRolloutsPerUpdateGroup: ActiveRollout[][] = await Promise.all( + updateGroups.map(async updateGroup => { + const maybeActiveRollouts = await Promise.all( + getPlatformsForUpdateGroup(updateGroup).map(async platform => { + const update = await findActiveRolloutUpdateAsync(graphqlClient, { + appId, + branchName, + runtimeVersion: updateGroup.runtimeVersion, + platform, + }); + return update ? { platform, update } : null; + }) + ); + return maybeActiveRollouts.filter((rollout): rollout is ActiveRollout => rollout !== null); + }) + ); + + if (activeRolloutsPerUpdateGroup.every(activeRollouts => activeRollouts.length === 0)) { + return updateGroups; + } + + if (rolloutPercentage !== undefined && rolloutPercentage < 100) { + throw new Error( + `Cannot roll out a new update to ${rolloutPercentage}% while a rollout is already in progress for the same runtime version. The users outside the new rollout are served the previous latest update, which is the update currently being rolled out, so it would jump to ${100 - rolloutPercentage}% instead of ending. Finish or revert the rollout in progress with eas update:rollout, then publish this one.` + ); + } + + for (const [index, activeRollouts] of activeRolloutsPerUpdateGroup.entries()) { + for (const { platform, update } of activeRollouts) { + Log.warn( + `A rollout is in progress on ${platform} for runtime version ${updateGroups[index].runtimeVersion}, currently at ${update.rolloutPercentage}%. Publishing over it ends that rollout.` + ); + } + } + + if (!forceEndActiveRollout) { + Log.warn( + 'Ending a rollout means the update being rolled out is served to every user until they receive this new one.' + ); + + if (nonInteractive) { + throw new Error( + 'Cannot publish over an in-progress rollout in non-interactive mode. Re-run with --force-end-active-rollout to end the rollout and publish anyway.' + ); + } + + const shouldEndRollout = await confirmAsync({ + message: 'End the rollout and publish anyway?', + initial: false, + }); + if (!shouldEndRollout) { + Errors.error('Aborted.', { exit: 1 }); + } + } + + return updateGroups.map((updateGroup, index) => { + const activeRollouts = activeRolloutsPerUpdateGroup[index]; + if (activeRollouts.length === 0) { + return updateGroup; + } + return { + ...updateGroup, + previousRolloutUpdateToClobberIdGroup: Object.fromEntries( + activeRollouts.map(({ platform, update }) => [platform, update.id]) + ), + }; + }); +} diff --git a/packages/eas-cli/src/update/republish.ts b/packages/eas-cli/src/update/republish.ts index 5099c2f300..2c1d7cea10 100644 --- a/packages/eas-cli/src/update/republish.ts +++ b/packages/eas-cli/src/update/republish.ts @@ -2,6 +2,7 @@ import { ExpoConfig } from '@expo/config'; import assert from 'assert'; import nullthrows from 'nullthrows'; +import { resolveUpdateGroupsSupersedingActiveRolloutsAsync } from './active-rollout'; import { getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync } from './getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync'; import { selectRuntimeAndGetLatestUpdateGroupForEachPublishPlatformOnBranchAsync, @@ -52,6 +53,7 @@ export async function republishAsync({ codeSigningInfo, json, rolloutPercentage, + activeRollout, }: { graphqlClient: ExpoGraphqlClient; app: { exp: ExpoConfig; projectId: string }; @@ -61,6 +63,7 @@ export async function republishAsync({ codeSigningInfo?: CodeSigningInfo; json?: boolean; rolloutPercentage?: number; + activeRollout?: { forceEndActiveRollout: boolean; nonInteractive: boolean }; }): Promise { const { branchName: targetBranchName, branchId: targetBranchId } = targetBranch; @@ -166,7 +169,7 @@ export async function republishAsync({ : null, }; - updatesRepublished = await PublishMutation.publishUpdateGroupAsync(graphqlClient, [ + const updateGroups = [ { branchId: targetBranchId, runtimeVersion, @@ -179,7 +182,20 @@ export async function republishAsync({ manifestHostOverride: updatesToPublish[0].manifestHostOverride, assetHostOverride: updatesToPublish[0].assetHostOverride, }, - ]); + ]; + + updatesRepublished = await PublishMutation.publishUpdateGroupAsync( + graphqlClient, + activeRollout + ? await resolveUpdateGroupsSupersedingActiveRolloutsAsync(graphqlClient, updateGroups, { + appId: app.projectId, + branchName: targetBranchName, + nonInteractive: activeRollout.nonInteractive, + forceEndActiveRollout: activeRollout.forceEndActiveRollout, + rolloutPercentage, + }) + : updateGroups + ); if (codeSigningInfo) { Log.log('๐Ÿ”’ Signing republished update group'); diff --git a/packages/eas-cli/src/update/roll-back-to-embedded.ts b/packages/eas-cli/src/update/roll-back-to-embedded.ts index 673c635dae..58a8a787a0 100644 --- a/packages/eas-cli/src/update/roll-back-to-embedded.ts +++ b/packages/eas-cli/src/update/roll-back-to-embedded.ts @@ -1,6 +1,7 @@ import { ExpoConfig } from '@expo/config'; import nullthrows from 'nullthrows'; +import { resolveUpdateGroupsSupersedingActiveRolloutsAsync } from './active-rollout'; import { UpdatePublishPlatform, getUpdateJsonInfosForUpdates } from './utils'; import { getUpdateGroupUrl } from '../build/utils/url'; import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; @@ -34,6 +35,7 @@ export async function publishRollBackToEmbeddedUpdateAsync({ platforms, runtimeVersion, json, + activeRollout, }: { graphqlClient: ExpoGraphqlClient; projectId: string; @@ -44,6 +46,7 @@ export async function publishRollBackToEmbeddedUpdateAsync({ platforms: UpdatePublishPlatform[]; runtimeVersion: string; json: boolean; + activeRollout?: { forceEndActiveRollout: boolean; nonInteractive: boolean }; }): Promise { const runtimeToPlatformsAndFingerprintInfoMapping = getRuntimeToPlatformsAndFingerprintInfoMappingFromRuntimeVersionInfoObjects( @@ -67,6 +70,9 @@ export async function publishRollBackToEmbeddedUpdateAsync({ codeSigningInfo, runtimeToPlatformsAndFingerprintInfoMapping, platforms, + projectId, + branchName: branch.name, + activeRollout, }); publishSpinner.succeed('Published!'); } catch (e) { @@ -127,6 +133,9 @@ async function publishRollbacksAsync({ codeSigningInfo, runtimeToPlatformsAndFingerprintInfoMapping, platforms, + projectId, + branchName, + activeRollout, }: { graphqlClient: ExpoGraphqlClient; updateMessage: string | undefined; @@ -136,6 +145,9 @@ async function publishRollbacksAsync({ platforms: UpdatePublishPlatform[]; })[]; platforms: UpdatePublishPlatform[]; + projectId: string; + branchName: string; + activeRollout?: { forceEndActiveRollout: boolean; nonInteractive: boolean }; }): Promise { const rollbackInfoGroups = Object.fromEntries(platforms.map(platform => [platform, true])); @@ -156,7 +168,17 @@ async function publishRollbacksAsync({ } ); - const newUpdates = await PublishMutation.publishUpdateGroupAsync(graphqlClient, updateGroups); + const newUpdates = await PublishMutation.publishUpdateGroupAsync( + graphqlClient, + activeRollout + ? await resolveUpdateGroupsSupersedingActiveRolloutsAsync(graphqlClient, updateGroups, { + appId: projectId, + branchName, + nonInteractive: activeRollout.nonInteractive, + forceEndActiveRollout: activeRollout.forceEndActiveRollout, + }) + : updateGroups + ); if (codeSigningInfo) { Log.log('๐Ÿ”’ Signing roll back'); From ba35624439d179626514523412392510cffccfe6 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Wed, 19 Aug 2026 14:30:14 -0700 Subject: [PATCH 02/10] [eas-cli] support superseding a rollout with a new rollout --- packages/eas-cli/src/commands/update/index.ts | 8 ++++- .../update/__tests__/active-rollout-test.ts | 33 ++++++++++++++++--- packages/eas-cli/src/update/active-rollout.ts | 11 +++---- 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/packages/eas-cli/src/commands/update/index.ts b/packages/eas-cli/src/commands/update/index.ts index fefafe4487..d139e6a41d 100644 --- a/packages/eas-cli/src/commands/update/index.ts +++ b/packages/eas-cli/src/commands/update/index.ts @@ -597,7 +597,13 @@ export default class UpdatePublish extends EasCommand { const updateGroupsToPublish = await resolveUpdateGroupsSupersedingActiveRolloutsAsync( graphqlClient, updateGroups, - { appId: projectId, branchName: branch.name, nonInteractive, forceEndActiveRollout } + { + appId: projectId, + branchName: branch.name, + nonInteractive, + forceEndActiveRollout, + rolloutPercentage, + } ); let newUpdates: UpdatePublishMutation['updateBranch']['publishUpdateGroups']; diff --git a/packages/eas-cli/src/update/__tests__/active-rollout-test.ts b/packages/eas-cli/src/update/__tests__/active-rollout-test.ts index 8f8ca47ebf..3bd0145eb2 100644 --- a/packages/eas-cli/src/update/__tests__/active-rollout-test.ts +++ b/packages/eas-cli/src/update/__tests__/active-rollout-test.ts @@ -2,10 +2,12 @@ import { resolveUpdateGroupsSupersedingActiveRolloutsAsync } from '../active-rol import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; import { AppPlatform, PublishUpdateGroupInput, UpdateFragment } from '../../graphql/generated'; import { UpdateQuery } from '../../graphql/queries/UpdateQuery'; +import Log from '../../log'; import { confirmAsync } from '../../prompts'; jest.mock('../../graphql/queries/UpdateQuery'); jest.mock('../../prompts'); +jest.mock('../../log'); const graphqlClient = {} as ExpoGraphqlClient; @@ -47,6 +49,7 @@ const resolveOptions = { appId: 'app-1234', branchName: 'main' }; beforeEach(() => { jest.mocked(UpdateQuery.viewUpdateGroupsOnBranchAsync).mockReset(); jest.mocked(confirmAsync).mockReset(); + jest.mocked(Log.warn).mockReset(); }); describe(resolveUpdateGroupsSupersedingActiveRolloutsAsync, () => { @@ -147,20 +150,40 @@ describe(resolveUpdateGroupsSupersedingActiveRolloutsAsync, () => { expect(result[1].previousRolloutUpdateToClobberIdGroup).toEqual({ ios: 'update-rollout' }); }); - it('rejects starting a partial rollout over a rollout in progress', async () => { + it('supersedes an in-progress rollout when the new update is itself a rollout', async () => { jest.mocked(UpdateQuery.viewUpdateGroupsOnBranchAsync).mockResolvedValue([[rolloutUpdateStub]]); - await expect( - resolveUpdateGroupsSupersedingActiveRolloutsAsync(graphqlClient, [updateGroupStub], { + const result = await resolveUpdateGroupsSupersedingActiveRolloutsAsync( + graphqlClient, + [updateGroupStub], + { ...resolveOptions, nonInteractive: false, forceEndActiveRollout: true, rolloutPercentage: 10, - }) - ).rejects.toThrow('it would jump to 90% instead of ending'); + } + ); + + expect(result[0].previousRolloutUpdateToClobberIdGroup).toEqual({ ios: 'update-rollout' }); expect(confirmAsync).not.toHaveBeenCalled(); }); + it('states the resulting split when the new update is itself a rollout', async () => { + jest.mocked(UpdateQuery.viewUpdateGroupsOnBranchAsync).mockResolvedValue([[rolloutUpdateStub]]); + jest.mocked(confirmAsync).mockResolvedValue(true); + + await resolveUpdateGroupsSupersedingActiveRolloutsAsync(graphqlClient, [updateGroupStub], { + ...resolveOptions, + nonInteractive: false, + forceEndActiveRollout: false, + rolloutPercentage: 10, + }); + + expect(jest.mocked(Log.warn).mock.calls.flat()).toContain( + 'Ending a rollout means the update being rolled out is served to the 90% of users not in this new rollout.' + ); + }); + it('requires the flag in non-interactive mode', async () => { jest.mocked(UpdateQuery.viewUpdateGroupsOnBranchAsync).mockResolvedValue([[rolloutUpdateStub]]); diff --git a/packages/eas-cli/src/update/active-rollout.ts b/packages/eas-cli/src/update/active-rollout.ts index 832c3125da..3f0d41d25c 100644 --- a/packages/eas-cli/src/update/active-rollout.ts +++ b/packages/eas-cli/src/update/active-rollout.ts @@ -82,12 +82,6 @@ export async function resolveUpdateGroupsSupersedingActiveRolloutsAsync( return updateGroups; } - if (rolloutPercentage !== undefined && rolloutPercentage < 100) { - throw new Error( - `Cannot roll out a new update to ${rolloutPercentage}% while a rollout is already in progress for the same runtime version. The users outside the new rollout are served the previous latest update, which is the update currently being rolled out, so it would jump to ${100 - rolloutPercentage}% instead of ending. Finish or revert the rollout in progress with eas update:rollout, then publish this one.` - ); - } - for (const [index, activeRollouts] of activeRolloutsPerUpdateGroup.entries()) { for (const { platform, update } of activeRollouts) { Log.warn( @@ -97,8 +91,11 @@ export async function resolveUpdateGroupsSupersedingActiveRolloutsAsync( } if (!forceEndActiveRollout) { + const isPartialRollout = rolloutPercentage !== undefined && rolloutPercentage < 100; Log.warn( - 'Ending a rollout means the update being rolled out is served to every user until they receive this new one.' + isPartialRollout + ? `Ending a rollout means the update being rolled out is served to the ${100 - rolloutPercentage}% of users not in this new rollout.` + : 'Ending a rollout means the update being rolled out is served to every user until they receive this new one.' ); if (nonInteractive) { From 1388425d0ed736c6bc49f2dcbc16caa791e5e489 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Wed, 19 Aug 2026 17:35:18 -0700 Subject: [PATCH 03/10] [eas-cli] prompt before starting the publish spinner --- .../update/__tests__/republish.test.ts | 13 ++++++-- .../__tests__/roll-back-to-embedded.test.ts | 13 ++++++-- packages/eas-cli/src/update/republish.ts | 27 ++++++++++------- .../src/update/roll-back-to-embedded.ts | 30 ++++++++++++------- 4 files changed, 55 insertions(+), 28 deletions(-) diff --git a/packages/eas-cli/src/commands/update/__tests__/republish.test.ts b/packages/eas-cli/src/commands/update/__tests__/republish.test.ts index eae80878ed..d6753c911e 100644 --- a/packages/eas-cli/src/commands/update/__tests__/republish.test.ts +++ b/packages/eas-cli/src/commands/update/__tests__/republish.test.ts @@ -51,9 +51,16 @@ jest.mock('../../../graphql/queries/BranchQuery'); jest.mock('../../../update/getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync'); jest.mock('../../../update/queries'); jest.mock('../../../ora', () => ({ - ora: () => ({ - start: () => ({ succeed: () => {}, fail: () => {} }), - }), + ora: () => { + const spinner = { + isSpinning: false, + start: () => spinner, + succeed: () => {}, + fail: () => {}, + stop: () => {}, + }; + return spinner; + }, })); jest.mock('../../../utils/code-signing'); jest.mock('../../../fetch'); diff --git a/packages/eas-cli/src/commands/update/__tests__/roll-back-to-embedded.test.ts b/packages/eas-cli/src/commands/update/__tests__/roll-back-to-embedded.test.ts index abb75ca60d..e19812a71a 100644 --- a/packages/eas-cli/src/commands/update/__tests__/roll-back-to-embedded.test.ts +++ b/packages/eas-cli/src/commands/update/__tests__/roll-back-to-embedded.test.ts @@ -56,9 +56,16 @@ jest.mock('../../../graphql/mutations/PublishMutation'); jest.mock('../../../graphql/queries/AppQuery'); jest.mock('../../../graphql/queries/UpdateQuery'); jest.mock('../../../ora', () => ({ - ora: () => ({ - start: () => ({ succeed: () => {}, fail: () => {}, stop: () => {} }), - }), + ora: () => { + const spinner = { + isSpinning: false, + start: () => spinner, + succeed: () => {}, + fail: () => {}, + stop: () => {}, + }; + return spinner; + }, })); jest.mock('../../../project/publish', () => ({ ...jest.requireActual('../../../project/publish'), diff --git a/packages/eas-cli/src/update/republish.ts b/packages/eas-cli/src/update/republish.ts index 2c1d7cea10..b1417fa947 100644 --- a/packages/eas-cli/src/update/republish.ts +++ b/packages/eas-cli/src/update/republish.ts @@ -122,7 +122,7 @@ export async function republishAsync({ ); } - const publishIndicator = ora('Republishing...').start(); + const publishIndicator = ora('Republishing...'); let updatesRepublished: Awaited>; try { @@ -184,17 +184,20 @@ export async function republishAsync({ }, ]; + const updateGroupsToPublish = activeRollout + ? await resolveUpdateGroupsSupersedingActiveRolloutsAsync(graphqlClient, updateGroups, { + appId: app.projectId, + branchName: targetBranchName, + nonInteractive: activeRollout.nonInteractive, + forceEndActiveRollout: activeRollout.forceEndActiveRollout, + rolloutPercentage, + }) + : updateGroups; + + publishIndicator.start(); updatesRepublished = await PublishMutation.publishUpdateGroupAsync( graphqlClient, - activeRollout - ? await resolveUpdateGroupsSupersedingActiveRolloutsAsync(graphqlClient, updateGroups, { - appId: app.projectId, - branchName: targetBranchName, - nonInteractive: activeRollout.nonInteractive, - forceEndActiveRollout: activeRollout.forceEndActiveRollout, - rolloutPercentage, - }) - : updateGroups + updateGroupsToPublish ); if (codeSigningInfo) { @@ -238,7 +241,9 @@ export async function republishAsync({ publishIndicator.succeed('Republished update group'); } catch (error: any) { - publishIndicator.fail('Failed to republish update group'); + if (publishIndicator.isSpinning) { + publishIndicator.fail('Failed to republish update group'); + } throw error; } diff --git a/packages/eas-cli/src/update/roll-back-to-embedded.ts b/packages/eas-cli/src/update/roll-back-to-embedded.ts index 58a8a787a0..d9ea33f280 100644 --- a/packages/eas-cli/src/update/roll-back-to-embedded.ts +++ b/packages/eas-cli/src/update/roll-back-to-embedded.ts @@ -9,7 +9,7 @@ import fetch from '../fetch'; import { PublishUpdateGroupInput, UpdatePublishMutation } from '../graphql/generated'; import { PublishMutation } from '../graphql/mutations/PublishMutation'; import Log, { link } from '../log'; -import { ora } from '../ora'; +import { Ora, ora } from '../ora'; import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils'; import { RuntimeVersionInfo, @@ -61,7 +61,7 @@ export async function publishRollBackToEmbeddedUpdateAsync({ ); let newUpdates: UpdatePublishMutation['updateBranch']['publishUpdateGroups']; - const publishSpinner = ora('Publishing...').start(); + const publishSpinner = ora('Publishing...'); try { newUpdates = await publishRollbacksAsync({ graphqlClient, @@ -73,10 +73,13 @@ export async function publishRollBackToEmbeddedUpdateAsync({ projectId, branchName: branch.name, activeRollout, + publishSpinner, }); publishSpinner.succeed('Published!'); } catch (e) { - publishSpinner.fail('Failed to publish updates'); + if (publishSpinner.isSpinning) { + publishSpinner.fail('Failed to publish updates'); + } throw e; } @@ -136,6 +139,7 @@ async function publishRollbacksAsync({ projectId, branchName, activeRollout, + publishSpinner, }: { graphqlClient: ExpoGraphqlClient; updateMessage: string | undefined; @@ -148,6 +152,7 @@ async function publishRollbacksAsync({ projectId: string; branchName: string; activeRollout?: { forceEndActiveRollout: boolean; nonInteractive: boolean }; + publishSpinner: Ora; }): Promise { const rollbackInfoGroups = Object.fromEntries(platforms.map(platform => [platform, true])); @@ -168,16 +173,19 @@ async function publishRollbacksAsync({ } ); + const updateGroupsToPublish = activeRollout + ? await resolveUpdateGroupsSupersedingActiveRolloutsAsync(graphqlClient, updateGroups, { + appId: projectId, + branchName, + nonInteractive: activeRollout.nonInteractive, + forceEndActiveRollout: activeRollout.forceEndActiveRollout, + }) + : updateGroups; + + publishSpinner.start(); const newUpdates = await PublishMutation.publishUpdateGroupAsync( graphqlClient, - activeRollout - ? await resolveUpdateGroupsSupersedingActiveRolloutsAsync(graphqlClient, updateGroups, { - appId: projectId, - branchName, - nonInteractive: activeRollout.nonInteractive, - forceEndActiveRollout: activeRollout.forceEndActiveRollout, - }) - : updateGroups + updateGroupsToPublish ); if (codeSigningInfo) { From 8fdcba49967f2ddb6f22c07ff0d25258130a9bfc Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Wed, 19 Aug 2026 19:56:08 -0700 Subject: [PATCH 04/10] [eas-cli] group the active rollout warning by runtime version --- .../update/__tests__/active-rollout-test.ts | 35 ++++++++++++- packages/eas-cli/src/update/active-rollout.ts | 51 +++++++++++++++---- 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/packages/eas-cli/src/update/__tests__/active-rollout-test.ts b/packages/eas-cli/src/update/__tests__/active-rollout-test.ts index 3bd0145eb2..dd5e1cd2b6 100644 --- a/packages/eas-cli/src/update/__tests__/active-rollout-test.ts +++ b/packages/eas-cli/src/update/__tests__/active-rollout-test.ts @@ -26,6 +26,7 @@ const rolloutUpdateStub: UpdateFragment = { codeSigningInfo: null, createdAt: '2022-01-01T12:00:00Z', rolloutPercentage: 25, + rolloutControlUpdate: { id: 'update-control', group: 'group-control-1234' }, }; const manifestStub = { @@ -180,10 +181,42 @@ describe(resolveUpdateGroupsSupersedingActiveRolloutsAsync, () => { }); expect(jest.mocked(Log.warn).mock.calls.flat()).toContain( - 'Ending a rollout means the update being rolled out is served to the 90% of users not in this new rollout.' + 'Ending the rollout makes your new update the latest for 10% of users. The other 90% receive the update that was rolling out.' ); }); + it('lists each platform on its own line, ordered and aligned', async () => { + jest + .mocked(UpdateQuery.viewUpdateGroupsOnBranchAsync) + .mockImplementation(async (_client, { filter }) => + filter?.platform === AppPlatform.Ios + ? [[rolloutUpdateStub]] + : [ + [ + { + ...rolloutUpdateStub, + id: 'update-android', + platform: 'android', + rolloutPercentage: 5, + }, + ], + ] + ); + + await resolveUpdateGroupsSupersedingActiveRolloutsAsync( + graphqlClient, + [{ ...updateGroupStub, rollBackToEmbeddedInfoGroup: { ios: true, android: true } }], + { ...resolveOptions, nonInteractive: false, forceEndActiveRollout: true } + ); + + const warnings = jest.mocked(Log.warn).mock.calls.flat(); + expect(warnings[0]).toBe('A rollout is in progress for runtime version 1.0.0:'); + expect(warnings.slice(1, 3)).toEqual([ + ' โ€ข Android 5% "rollout message" group group-ro control group-co', + ' โ€ข iOS 25% "rollout message" group group-ro control group-co', + ]); + }); + it('requires the flag in non-interactive mode', async () => { jest.mocked(UpdateQuery.viewUpdateGroupsOnBranchAsync).mockResolvedValue([[rolloutUpdateStub]]); diff --git a/packages/eas-cli/src/update/active-rollout.ts b/packages/eas-cli/src/update/active-rollout.ts index 3f0d41d25c..2978a7ebad 100644 --- a/packages/eas-cli/src/update/active-rollout.ts +++ b/packages/eas-cli/src/update/active-rollout.ts @@ -5,6 +5,7 @@ import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGr import { PublishUpdateGroupInput, UpdateFragment } from '../graphql/generated'; import { UpdateQuery } from '../graphql/queries/UpdateQuery'; import Log from '../log'; +import { appPlatformDisplayNames } from '../platform'; import { confirmAsync } from '../prompts'; type ActiveRollout = { platform: UpdatePublishPlatform; update: UpdateFragment }; @@ -83,21 +84,51 @@ export async function resolveUpdateGroupsSupersedingActiveRolloutsAsync( } for (const [index, activeRollouts] of activeRolloutsPerUpdateGroup.entries()) { - for (const { platform, update } of activeRollouts) { - Log.warn( - `A rollout is in progress on ${platform} for runtime version ${updateGroups[index].runtimeVersion}, currently at ${update.rolloutPercentage}%. Publishing over it ends that rollout.` - ); + if (activeRollouts.length === 0) { + continue; } - } - if (!forceEndActiveRollout) { - const isPartialRollout = rolloutPercentage !== undefined && rolloutPercentage < 100; Log.warn( - isPartialRollout - ? `Ending a rollout means the update being rolled out is served to the ${100 - rolloutPercentage}% of users not in this new rollout.` - : 'Ending a rollout means the update being rolled out is served to every user until they receive this new one.' + `A rollout is in progress for runtime version ${updateGroups[index].runtimeVersion}:` + ); + + const rolloutsByPlatform = activeRollouts + .map(({ platform, update }) => ({ + platformName: appPlatformDisplayNames[updatePublishPlatformToAppPlatform[platform]], + percentage: `${update.rolloutPercentage}%`, + message: update.message, + group: update.group, + controlGroup: update.rolloutControlUpdate?.group, + })) + .sort((a, b) => a.platformName.localeCompare(b.platformName)); + const platformNameWidth = Math.max( + ...rolloutsByPlatform.map(({ platformName }) => platformName.length) + ); + const percentageWidth = Math.max( + ...rolloutsByPlatform.map(({ percentage }) => percentage.length) ); + for (const { platformName, percentage, message, group, controlGroup } of rolloutsByPlatform) { + const columns = [ + platformName.padEnd(platformNameWidth), + percentage.padEnd(percentageWidth), + message ? `"${message}"` : null, + `group ${group.slice(0, 8)}`, + controlGroup ? `control ${controlGroup.slice(0, 8)}` : null, + ].filter(column => column !== null); + Log.warn(` โ€ข ${columns.join(' ')}`); + } + } + + const isPartialRollout = rolloutPercentage !== undefined && rolloutPercentage < 100; + Log.warn( + isPartialRollout + ? `Ending the rollout makes your new update the latest for ${rolloutPercentage}% of users. The other ${100 - rolloutPercentage}% receive the update that was rolling out.` + : 'Ending the rollout makes your new update the latest, so every user receives it instead. The update that was rolling out stops being served.' + ); + Log.newLine(); + + if (!forceEndActiveRollout) { if (nonInteractive) { throw new Error( 'Cannot publish over an in-progress rollout in non-interactive mode. Re-run with --force-end-active-rollout to end the rollout and publish anyway.' From 3ae4e98a83aab933d7af98e77aad0091d8a5bf60 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Wed, 19 Aug 2026 21:10:35 -0700 Subject: [PATCH 05/10] [eas-cli] cover the active rollout paths --- .../update/__tests__/republish.test.ts | 31 ++++++++++++-- .../__tests__/roll-back-to-embedded.test.ts | 40 ++++++++++++++++-- .../update/__tests__/rollback.test.ts | 22 ++++++++++ .../update/__tests__/active-rollout-test.ts | 42 +++++++++++++++++++ 4 files changed, 127 insertions(+), 8 deletions(-) diff --git a/packages/eas-cli/src/commands/update/__tests__/republish.test.ts b/packages/eas-cli/src/commands/update/__tests__/republish.test.ts index d6753c911e..b846b6432e 100644 --- a/packages/eas-cli/src/commands/update/__tests__/republish.test.ts +++ b/packages/eas-cli/src/commands/update/__tests__/republish.test.ts @@ -54,10 +54,19 @@ jest.mock('../../../ora', () => ({ ora: () => { const spinner = { isSpinning: false, - start: () => spinner, - succeed: () => {}, - fail: () => {}, - stop: () => {}, + start: () => { + spinner.isSpinning = true; + return spinner; + }, + succeed: () => { + spinner.isSpinning = false; + }, + fail: () => { + spinner.isSpinning = false; + }, + stop: () => { + spinner.isSpinning = false; + }, }; return spinner; }, @@ -138,6 +147,20 @@ describe(UpdateRepublish.name, () => { ); }); + it('reports a failed republish and rethrows', async () => { + const flags = ['--group=1234', '--message=test-republish']; + + mockTestProject(); + jest.mocked(UpdateQuery.viewUpdateGroupAsync).mockResolvedValue([updateStub]); + jest + .mocked(PublishMutation.publishUpdateGroupAsync) + .mockRejectedValue(new Error('republish exploded')); + + await expect(new UpdateRepublish(flags, commandOptions).run()).rejects.toThrow( + 'republish exploded' + ); + }); + it('re-creates update with --group and --message', async () => { const flags = ['--group=1234', '--message=test-republish']; diff --git a/packages/eas-cli/src/commands/update/__tests__/roll-back-to-embedded.test.ts b/packages/eas-cli/src/commands/update/__tests__/roll-back-to-embedded.test.ts index e19812a71a..ff5e0412f9 100644 --- a/packages/eas-cli/src/commands/update/__tests__/roll-back-to-embedded.test.ts +++ b/packages/eas-cli/src/commands/update/__tests__/roll-back-to-embedded.test.ts @@ -59,10 +59,19 @@ jest.mock('../../../ora', () => ({ ora: () => { const spinner = { isSpinning: false, - start: () => spinner, - succeed: () => {}, - fail: () => {}, - stop: () => {}, + start: () => { + spinner.isSpinning = true; + return spinner; + }, + succeed: () => { + spinner.isSpinning = false; + }, + fail: () => { + spinner.isSpinning = false; + }, + stop: () => { + spinner.isSpinning = false; + }, }; return spinner; }, @@ -119,6 +128,29 @@ describe(UpdateRollBackToEmbedded.name, () => { expect(PublishMutation.publishUpdateGroupAsync).toHaveBeenCalled(); }); + it('reports a failed publish and rethrows', async () => { + const flags = [ + '--non-interactive', + '--branch=branch123', + '--message=abc', + '--runtime-version=exposdk:47.0.0', + ]; + + mockTestProject(); + + jest.mocked(ensureBranchExistsAsync).mockResolvedValue({ + branch: { id: 'branch123', name: 'wat' }, + createdBranch: false, + }); + jest + .mocked(PublishMutation.publishUpdateGroupAsync) + .mockRejectedValue(new Error('publish exploded')); + + await expect(new UpdateRollBackToEmbedded(flags, commandOptions).run()).rejects.toThrow( + 'publish exploded' + ); + }); + it('creates a roll back to embedded with --non-interactive, --channel, --message, and --runtime-version', async () => { const flags = [ '--non-interactive', diff --git a/packages/eas-cli/src/commands/update/__tests__/rollback.test.ts b/packages/eas-cli/src/commands/update/__tests__/rollback.test.ts index 85d31cafc9..b8a2d417bd 100644 --- a/packages/eas-cli/src/commands/update/__tests__/rollback.test.ts +++ b/packages/eas-cli/src/commands/update/__tests__/rollback.test.ts @@ -12,6 +12,7 @@ import { jester } from '../../../credentials/__tests__/fixtures-constants'; import { UpdateFragment } from '../../../graphql/generated'; import { AppQuery } from '../../../graphql/queries/AppQuery'; import { UpdateQuery } from '../../../graphql/queries/UpdateQuery'; +import { promptAsync } from '../../../prompts'; import UpdateRepublish from '../republish'; import UpdateRollBackToEmbedded from '../roll-back-to-embedded'; import UpdateRollback from '../rollback'; @@ -40,6 +41,7 @@ jest.mock('@expo/config'); jest.mock('../../../commandUtils/context/contextUtils/getProjectIdAsync'); jest.mock('../../../graphql/queries/AppQuery'); jest.mock('../../../graphql/queries/UpdateQuery'); +jest.mock('../../../prompts'); describe(UpdateRollback.name, () => { beforeEach(() => { @@ -221,6 +223,26 @@ describe(UpdateRollback.name, () => { ]); }); + it('forwards --force-end-active-rollout when interactively choosing a published update', async () => { + mockTestProject(); + jest.mocked(promptAsync).mockResolvedValue({ choice: 'published' }); + + await new UpdateRollback(['--force-end-active-rollout'], commandOptions).run(); + + expect(UpdateRollBackToEmbedded.run).not.toHaveBeenCalled(); + expect(UpdateRepublish.run).toHaveBeenCalledWith(['--force-end-active-rollout']); + }); + + it('forwards --force-end-active-rollout when interactively choosing the embedded update', async () => { + mockTestProject(); + jest.mocked(promptAsync).mockResolvedValue({ choice: 'embedded' }); + + await new UpdateRollback(['--force-end-active-rollout'], commandOptions).run(); + + expect(UpdateRepublish.run).not.toHaveBeenCalled(); + expect(UpdateRollBackToEmbedded.run).toHaveBeenCalledWith(['--force-end-active-rollout']); + }); + it('errors when the source group is not the latest update for its runtime version', async () => { const flags = ['group-source', '--non-interactive']; mockTestProject(); diff --git a/packages/eas-cli/src/update/__tests__/active-rollout-test.ts b/packages/eas-cli/src/update/__tests__/active-rollout-test.ts index dd5e1cd2b6..13ceb2d06e 100644 --- a/packages/eas-cli/src/update/__tests__/active-rollout-test.ts +++ b/packages/eas-cli/src/update/__tests__/active-rollout-test.ts @@ -217,6 +217,48 @@ describe(resolveUpdateGroupsSupersedingActiveRolloutsAsync, () => { ]); }); + it('ignores platforms that cannot carry an update', async () => { + jest.mocked(UpdateQuery.viewUpdateGroupsOnBranchAsync).mockResolvedValue([[rolloutUpdateStub]]); + + const result = await resolveUpdateGroupsSupersedingActiveRolloutsAsync( + graphqlClient, + [{ ...updateGroupStub, rollBackToEmbeddedInfoGroup: { ios: true, web: true } }], + { ...resolveOptions, nonInteractive: false, forceEndActiveRollout: true } + ); + + expect(UpdateQuery.viewUpdateGroupsOnBranchAsync).toHaveBeenCalledTimes(1); + expect(result[0].previousRolloutUpdateToClobberIdGroup).toEqual({ ios: 'update-rollout' }); + }); + + it('leaves an update group with no platforms untouched', async () => { + const emptyGroup = { branchId: 'branch-1234', runtimeVersion: '1.0.0' }; + + const result = await resolveUpdateGroupsSupersedingActiveRolloutsAsync( + graphqlClient, + [emptyGroup], + { ...resolveOptions, nonInteractive: false, forceEndActiveRollout: true } + ); + + expect(UpdateQuery.viewUpdateGroupsOnBranchAsync).not.toHaveBeenCalled(); + expect(result).toEqual([emptyGroup]); + }); + + it('omits the message and control columns when the rollout has neither', async () => { + jest + .mocked(UpdateQuery.viewUpdateGroupsOnBranchAsync) + .mockResolvedValue([ + [{ ...rolloutUpdateStub, message: null, rolloutControlUpdate: null }], + ]); + + await resolveUpdateGroupsSupersedingActiveRolloutsAsync(graphqlClient, [updateGroupStub], { + ...resolveOptions, + nonInteractive: false, + forceEndActiveRollout: true, + }); + + expect(jest.mocked(Log.warn).mock.calls.flat()[1]).toBe(' โ€ข iOS 25% group group-ro'); + }); + it('requires the flag in non-interactive mode', async () => { jest.mocked(UpdateQuery.viewUpdateGroupsOnBranchAsync).mockResolvedValue([[rolloutUpdateStub]]); From 7dd7d15738248b119129a99304185dd4c571285a Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Thu, 20 Aug 2026 08:39:07 -0700 Subject: [PATCH 06/10] [eas-cli] explain why the superseded rollout serves more users --- .../eas-cli/src/update/__tests__/active-rollout-test.ts | 6 ++---- packages/eas-cli/src/update/active-rollout.ts | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/eas-cli/src/update/__tests__/active-rollout-test.ts b/packages/eas-cli/src/update/__tests__/active-rollout-test.ts index 13ceb2d06e..1e12614ace 100644 --- a/packages/eas-cli/src/update/__tests__/active-rollout-test.ts +++ b/packages/eas-cli/src/update/__tests__/active-rollout-test.ts @@ -181,7 +181,7 @@ describe(resolveUpdateGroupsSupersedingActiveRolloutsAsync, () => { }); expect(jest.mocked(Log.warn).mock.calls.flat()).toContain( - 'Ending the rollout makes your new update the latest for 10% of users. The other 90% receive the update that was rolling out.' + 'Ending the rollout makes your new update the latest for 10% of users. The update that was rolling out becomes the control update for your rollout, so its share grows to 90%.' ); }); @@ -246,9 +246,7 @@ describe(resolveUpdateGroupsSupersedingActiveRolloutsAsync, () => { it('omits the message and control columns when the rollout has neither', async () => { jest .mocked(UpdateQuery.viewUpdateGroupsOnBranchAsync) - .mockResolvedValue([ - [{ ...rolloutUpdateStub, message: null, rolloutControlUpdate: null }], - ]); + .mockResolvedValue([[{ ...rolloutUpdateStub, message: null, rolloutControlUpdate: null }]]); await resolveUpdateGroupsSupersedingActiveRolloutsAsync(graphqlClient, [updateGroupStub], { ...resolveOptions, diff --git a/packages/eas-cli/src/update/active-rollout.ts b/packages/eas-cli/src/update/active-rollout.ts index 2978a7ebad..58d283d3a2 100644 --- a/packages/eas-cli/src/update/active-rollout.ts +++ b/packages/eas-cli/src/update/active-rollout.ts @@ -88,9 +88,7 @@ export async function resolveUpdateGroupsSupersedingActiveRolloutsAsync( continue; } - Log.warn( - `A rollout is in progress for runtime version ${updateGroups[index].runtimeVersion}:` - ); + Log.warn(`A rollout is in progress for runtime version ${updateGroups[index].runtimeVersion}:`); const rolloutsByPlatform = activeRollouts .map(({ platform, update }) => ({ @@ -123,7 +121,7 @@ export async function resolveUpdateGroupsSupersedingActiveRolloutsAsync( const isPartialRollout = rolloutPercentage !== undefined && rolloutPercentage < 100; Log.warn( isPartialRollout - ? `Ending the rollout makes your new update the latest for ${rolloutPercentage}% of users. The other ${100 - rolloutPercentage}% receive the update that was rolling out.` + ? `Ending the rollout makes your new update the latest for ${rolloutPercentage}% of users. The update that was rolling out becomes the control update for your rollout, so its share grows to ${100 - rolloutPercentage}%.` : 'Ending the rollout makes your new update the latest, so every user receives it instead. The update that was rolling out stops being served.' ); Log.newLine(); From 3a0975ac8c85ee08e0cd89fc432e1a257afef2e4 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Thu, 20 Aug 2026 09:32:06 -0700 Subject: [PATCH 07/10] [eas-cli] cover publishing without the rollout check --- CHANGELOG.md | 1 + .../update/__tests__/republish.test.ts | 26 +++++++++++++++ .../__tests__/roll-back-to-embedded.test.ts | 32 +++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a34a4b5e3..32a25c14e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ This is the log of notable changes to EAS CLI and related packages. ### ๐ŸŽ‰ New features - [eas-update] Add `--force-end-active-rollout` to `eas update`, `eas update:republish`, `eas update:roll-back-to-embedded` and `eas update:rollback`, so these commands can publish over a rollout that is in progress instead of being rejected. Without the flag, the commands warn and ask for confirmation first. ([#4233](https://github.com/expo/eas-cli/pull/4233) by [@gwdp](https://github.com/gwdp)) + ### ๐Ÿ› Bug fixes ### ๐Ÿงน Chores diff --git a/packages/eas-cli/src/commands/update/__tests__/republish.test.ts b/packages/eas-cli/src/commands/update/__tests__/republish.test.ts index b846b6432e..1d7656fc17 100644 --- a/packages/eas-cli/src/commands/update/__tests__/republish.test.ts +++ b/packages/eas-cli/src/commands/update/__tests__/republish.test.ts @@ -21,6 +21,7 @@ import { getManifestBodyAsync, signBody, } from '../../../utils/code-signing'; +import { republishAsync } from '../../../update/republish'; import UpdateRepublish from '../republish'; const projectRoot = '/test-project'; @@ -147,6 +148,31 @@ describe(UpdateRepublish.name, () => { ); }); + it('republishes without checking rollouts when no caller opts in', async () => { + mockTestProject(); + jest + .mocked(PublishMutation.publishUpdateGroupAsync) + .mockResolvedValue([{ ...updateStub, id: 'update-new', platform: 'ios' }]); + + await republishAsync({ + graphqlClient: instance(mock({})), + app: { exp: { name: 'testing 123', slug: 'testing-123' } as ExpoConfig, projectId: '1234' }, + updatesToPublish: [ + { + ...updateStub, + groupId: updateStub.group, + branchId: updateStub.branch.id, + branchName: updateStub.branch.name, + }, + ], + targetBranch: { branchId: updateStub.branch.id, branchName: updateStub.branch.name }, + updateMessage: 'no rollout check', + json: false, + }); + + expect(UpdateQuery.viewUpdateGroupsOnBranchAsync).not.toHaveBeenCalled(); + }); + it('reports a failed republish and rethrows', async () => { const flags = ['--group=1234', '--message=test-republish']; diff --git a/packages/eas-cli/src/commands/update/__tests__/roll-back-to-embedded.test.ts b/packages/eas-cli/src/commands/update/__tests__/roll-back-to-embedded.test.ts index ff5e0412f9..b3b16fba7b 100644 --- a/packages/eas-cli/src/commands/update/__tests__/roll-back-to-embedded.test.ts +++ b/packages/eas-cli/src/commands/update/__tests__/roll-back-to-embedded.test.ts @@ -19,8 +19,10 @@ import { jester } from '../../../credentials/__tests__/fixtures-constants'; import { UpdateFragment } from '../../../graphql/generated'; import { PublishMutation } from '../../../graphql/mutations/PublishMutation'; import { AppQuery } from '../../../graphql/queries/AppQuery'; +import { UpdateQuery } from '../../../graphql/queries/UpdateQuery'; import { getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync } from '../../../update/getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync'; import { resolveVcsClient } from '../../../vcs'; +import { publishRollBackToEmbeddedUpdateAsync } from '../../../update/roll-back-to-embedded'; import UpdateRollBackToEmbedded from '../roll-back-to-embedded'; const projectRoot = '/test-project'; @@ -87,6 +89,7 @@ jest.mock('../../../project/publish', () => ({ describe(UpdateRollBackToEmbedded.name, () => { afterEach(() => { vol.reset(); + jest.clearAllMocks(); }); it('errors with both --channel and --branch', async () => { @@ -128,6 +131,35 @@ describe(UpdateRollBackToEmbedded.name, () => { expect(PublishMutation.publishUpdateGroupAsync).toHaveBeenCalled(); }); + it('publishes without checking rollouts when no caller opts in', async () => { + mockTestProject(); + const runtimeVersion = 'exposdk:47.0.0'; + jest + .mocked(PublishMutation.publishUpdateGroupAsync) + .mockResolvedValue([ + { ...updateStub, platform: 'ios', runtime: { id: 'r1', version: runtimeVersion } }, + ]); + + await publishRollBackToEmbeddedUpdateAsync({ + graphqlClient: instance(mock({})), + projectId: '1234', + exp: { name: 'testing 123', slug: 'testing-123' } as ExpoConfig, + updateMessage: 'no rollout check', + branch: { id: 'branch123', name: 'main' }, + codeSigningInfo: undefined, + platforms: ['ios'], + runtimeVersion, + json: false, + }); + + expect(UpdateQuery.viewUpdateGroupsOnBranchAsync).not.toHaveBeenCalled(); + expect(PublishMutation.publishUpdateGroupAsync).toHaveBeenCalledWith(expect.any(Object), [ + expect.not.objectContaining({ + previousRolloutUpdateToClobberIdGroup: expect.anything(), + }), + ]); + }); + it('reports a failed publish and rethrows', async () => { const flags = [ '--non-interactive', From 99805ada03e3532609096e4dfc7c1c643b262ed8 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Thu, 20 Aug 2026 12:25:45 -0700 Subject: [PATCH 08/10] [eas-cli] refuse rolling out over a rollout in progress --- .../update/__tests__/active-rollout-test.ts | 30 ++++--------------- packages/eas-cli/src/update/active-rollout.ts | 11 ++++--- 2 files changed, 12 insertions(+), 29 deletions(-) diff --git a/packages/eas-cli/src/update/__tests__/active-rollout-test.ts b/packages/eas-cli/src/update/__tests__/active-rollout-test.ts index 1e12614ace..195ec53105 100644 --- a/packages/eas-cli/src/update/__tests__/active-rollout-test.ts +++ b/packages/eas-cli/src/update/__tests__/active-rollout-test.ts @@ -151,40 +151,20 @@ describe(resolveUpdateGroupsSupersedingActiveRolloutsAsync, () => { expect(result[1].previousRolloutUpdateToClobberIdGroup).toEqual({ ios: 'update-rollout' }); }); - it('supersedes an in-progress rollout when the new update is itself a rollout', async () => { + it('rejects rolling out a new update over a rollout in progress', async () => { jest.mocked(UpdateQuery.viewUpdateGroupsOnBranchAsync).mockResolvedValue([[rolloutUpdateStub]]); - const result = await resolveUpdateGroupsSupersedingActiveRolloutsAsync( - graphqlClient, - [updateGroupStub], - { + await expect( + resolveUpdateGroupsSupersedingActiveRolloutsAsync(graphqlClient, [updateGroupStub], { ...resolveOptions, nonInteractive: false, forceEndActiveRollout: true, rolloutPercentage: 10, - } - ); - - expect(result[0].previousRolloutUpdateToClobberIdGroup).toEqual({ ios: 'update-rollout' }); + }) + ).rejects.toThrow('Cannot roll out a new update while a rollout is already in progress'); expect(confirmAsync).not.toHaveBeenCalled(); }); - it('states the resulting split when the new update is itself a rollout', async () => { - jest.mocked(UpdateQuery.viewUpdateGroupsOnBranchAsync).mockResolvedValue([[rolloutUpdateStub]]); - jest.mocked(confirmAsync).mockResolvedValue(true); - - await resolveUpdateGroupsSupersedingActiveRolloutsAsync(graphqlClient, [updateGroupStub], { - ...resolveOptions, - nonInteractive: false, - forceEndActiveRollout: false, - rolloutPercentage: 10, - }); - - expect(jest.mocked(Log.warn).mock.calls.flat()).toContain( - 'Ending the rollout makes your new update the latest for 10% of users. The update that was rolling out becomes the control update for your rollout, so its share grows to 90%.' - ); - }); - it('lists each platform on its own line, ordered and aligned', async () => { jest .mocked(UpdateQuery.viewUpdateGroupsOnBranchAsync) diff --git a/packages/eas-cli/src/update/active-rollout.ts b/packages/eas-cli/src/update/active-rollout.ts index 58d283d3a2..7aa9808db4 100644 --- a/packages/eas-cli/src/update/active-rollout.ts +++ b/packages/eas-cli/src/update/active-rollout.ts @@ -118,11 +118,14 @@ export async function resolveUpdateGroupsSupersedingActiveRolloutsAsync( } } - const isPartialRollout = rolloutPercentage !== undefined && rolloutPercentage < 100; + if (rolloutPercentage !== undefined) { + throw new Error( + 'Cannot roll out a new update while a rollout is already in progress for the same runtime version. The update being rolled out would become the control update for your rollout and be served to more users rather than fewer. Finish or revert the rollout in progress with eas update:rollout, then publish this one.' + ); + } + Log.warn( - isPartialRollout - ? `Ending the rollout makes your new update the latest for ${rolloutPercentage}% of users. The update that was rolling out becomes the control update for your rollout, so its share grows to ${100 - rolloutPercentage}%.` - : 'Ending the rollout makes your new update the latest, so every user receives it instead. The update that was rolling out stops being served.' + 'Ending the rollout makes your new update the latest, so every user receives it instead. The update that was rolling out stops being served.' ); Log.newLine(); From 243e02de295e766b7f20e64a14be567f6addc3f2 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Thu, 20 Aug 2026 23:07:03 -0700 Subject: [PATCH 09/10] [eas-cli] rewrite the rollout publish errors as what, why, how --- packages/eas-cli/src/update/__tests__/active-rollout-test.ts | 2 +- packages/eas-cli/src/update/active-rollout.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/eas-cli/src/update/__tests__/active-rollout-test.ts b/packages/eas-cli/src/update/__tests__/active-rollout-test.ts index 195ec53105..bd8053c54a 100644 --- a/packages/eas-cli/src/update/__tests__/active-rollout-test.ts +++ b/packages/eas-cli/src/update/__tests__/active-rollout-test.ts @@ -161,7 +161,7 @@ describe(resolveUpdateGroupsSupersedingActiveRolloutsAsync, () => { forceEndActiveRollout: true, rolloutPercentage: 10, }) - ).rejects.toThrow('Cannot roll out a new update while a rollout is already in progress'); + ).rejects.toThrow('Cannot start a rollout while another rollout is in progress'); expect(confirmAsync).not.toHaveBeenCalled(); }); diff --git a/packages/eas-cli/src/update/active-rollout.ts b/packages/eas-cli/src/update/active-rollout.ts index 7aa9808db4..39ad4efb99 100644 --- a/packages/eas-cli/src/update/active-rollout.ts +++ b/packages/eas-cli/src/update/active-rollout.ts @@ -120,7 +120,7 @@ export async function resolveUpdateGroupsSupersedingActiveRolloutsAsync( if (rolloutPercentage !== undefined) { throw new Error( - 'Cannot roll out a new update while a rollout is already in progress for the same runtime version. The update being rolled out would become the control update for your rollout and be served to more users rather than fewer. Finish or revert the rollout in progress with eas update:rollout, then publish this one.' + 'Cannot start a rollout while another rollout is in progress. Only one rollout can be in progress for a given branch, platform, and runtime version. Set the rollout in progress to 100% with eas update:edit, or revert it with eas update:revert-update-rollout, then publish again.' ); } @@ -132,7 +132,7 @@ export async function resolveUpdateGroupsSupersedingActiveRolloutsAsync( if (!forceEndActiveRollout) { if (nonInteractive) { throw new Error( - 'Cannot publish over an in-progress rollout in non-interactive mode. Re-run with --force-end-active-rollout to end the rollout and publish anyway.' + 'Cannot supersede the rollout in progress. Ending a rollout requires confirmation, which is unavailable in non-interactive mode. Re-run with --force-end-active-rollout to end the rollout and publish.' ); } From 09cd9e0019c18384804c45d7f136374d45b587e6 Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Thu, 20 Aug 2026 23:32:58 -0700 Subject: [PATCH 10/10] [eas-cli] render the active rollout warning with renderTextTable --- .../update/__tests__/active-rollout-test.ts | 18 +++++++--- packages/eas-cli/src/update/active-rollout.ts | 35 +++++++------------ 2 files changed, 26 insertions(+), 27 deletions(-) diff --git a/packages/eas-cli/src/update/__tests__/active-rollout-test.ts b/packages/eas-cli/src/update/__tests__/active-rollout-test.ts index bd8053c54a..0f7a784f47 100644 --- a/packages/eas-cli/src/update/__tests__/active-rollout-test.ts +++ b/packages/eas-cli/src/update/__tests__/active-rollout-test.ts @@ -1,3 +1,5 @@ +import chalk from 'chalk'; + import { resolveUpdateGroupsSupersedingActiveRolloutsAsync } from '../active-rollout'; import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient'; import { AppPlatform, PublishUpdateGroupInput, UpdateFragment } from '../../graphql/generated'; @@ -191,9 +193,11 @@ describe(resolveUpdateGroupsSupersedingActiveRolloutsAsync, () => { const warnings = jest.mocked(Log.warn).mock.calls.flat(); expect(warnings[0]).toBe('A rollout is in progress for runtime version 1.0.0:'); - expect(warnings.slice(1, 3)).toEqual([ - ' โ€ข Android 5% "rollout message" group group-ro control group-co', - ' โ€ข iOS 25% "rollout message" group group-ro control group-co', + expect(String(warnings[1]).split('\n')).toEqual([ + chalk.bold('Platform Rollout Message Update group Control update'), + '-------- ------- --------------- ------------ --------------', + 'Android 5% rollout message group-ro group-co ', + 'iOS 25% rollout message group-ro group-co ', ]); }); @@ -223,7 +227,7 @@ describe(resolveUpdateGroupsSupersedingActiveRolloutsAsync, () => { expect(result).toEqual([emptyGroup]); }); - it('omits the message and control columns when the rollout has neither', async () => { + it('leaves the message and control cells empty when the rollout has neither', async () => { jest .mocked(UpdateQuery.viewUpdateGroupsOnBranchAsync) .mockResolvedValue([[{ ...rolloutUpdateStub, message: null, rolloutControlUpdate: null }]]); @@ -234,7 +238,11 @@ describe(resolveUpdateGroupsSupersedingActiveRolloutsAsync, () => { forceEndActiveRollout: true, }); - expect(jest.mocked(Log.warn).mock.calls.flat()[1]).toBe(' โ€ข iOS 25% group group-ro'); + expect(String(jest.mocked(Log.warn).mock.calls.flat()[1]).split('\n')).toEqual([ + chalk.bold('Platform Rollout Message Update group Control update'), + '-------- ------- ------- ------------ --------------', + 'iOS 25% group-ro ', + ]); }); it('requires the flag in non-interactive mode', async () => { diff --git a/packages/eas-cli/src/update/active-rollout.ts b/packages/eas-cli/src/update/active-rollout.ts index 39ad4efb99..e3d12bd93d 100644 --- a/packages/eas-cli/src/update/active-rollout.ts +++ b/packages/eas-cli/src/update/active-rollout.ts @@ -7,6 +7,7 @@ import { UpdateQuery } from '../graphql/queries/UpdateQuery'; import Log from '../log'; import { appPlatformDisplayNames } from '../platform'; import { confirmAsync } from '../prompts'; +import renderTextTable from '../utils/renderTextTable'; type ActiveRollout = { platform: UpdatePublishPlatform; update: UpdateFragment }; @@ -90,32 +91,22 @@ export async function resolveUpdateGroupsSupersedingActiveRolloutsAsync( Log.warn(`A rollout is in progress for runtime version ${updateGroups[index].runtimeVersion}:`); - const rolloutsByPlatform = activeRollouts + const rows = activeRollouts .map(({ platform, update }) => ({ platformName: appPlatformDisplayNames[updatePublishPlatformToAppPlatform[platform]], - percentage: `${update.rolloutPercentage}%`, - message: update.message, - group: update.group, - controlGroup: update.rolloutControlUpdate?.group, + update, })) - .sort((a, b) => a.platformName.localeCompare(b.platformName)); - const platformNameWidth = Math.max( - ...rolloutsByPlatform.map(({ platformName }) => platformName.length) + .sort((a, b) => a.platformName.localeCompare(b.platformName)) + .map(({ platformName, update }) => [ + platformName, + `${update.rolloutPercentage}%`, + update.message ?? '', + update.group.slice(0, 8), + update.rolloutControlUpdate?.group.slice(0, 8) ?? '', + ]); + Log.warn( + renderTextTable(['Platform', 'Rollout', 'Message', 'Update group', 'Control update'], rows) ); - const percentageWidth = Math.max( - ...rolloutsByPlatform.map(({ percentage }) => percentage.length) - ); - - for (const { platformName, percentage, message, group, controlGroup } of rolloutsByPlatform) { - const columns = [ - platformName.padEnd(platformNameWidth), - percentage.padEnd(percentageWidth), - message ? `"${message}"` : null, - `group ${group.slice(0, 8)}`, - controlGroup ? `control ${controlGroup.slice(0, 8)}` : null, - ].filter(column => column !== null); - Log.warn(` โ€ข ${columns.join(' ')}`); - } } if (rolloutPercentage !== undefined) {