diff --git a/CHANGELOG.md b/CHANGELOG.md index 271ae9a3f6..32a25c14e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ 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 eae80878ed..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'; @@ -51,9 +52,25 @@ 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.isSpinning = true; + return spinner; + }, + succeed: () => { + spinner.isSpinning = false; + }, + fail: () => { + spinner.isSpinning = false; + }, + stop: () => { + spinner.isSpinning = false; + }, + }; + return spinner; + }, })); jest.mock('../../../utils/code-signing'); jest.mock('../../../fetch'); @@ -131,6 +148,45 @@ 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']; + + 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 abb75ca60d..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'; @@ -56,9 +58,25 @@ 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.isSpinning = true; + return spinner; + }, + succeed: () => { + spinner.isSpinning = false; + }, + fail: () => { + spinner.isSpinning = false; + }, + stop: () => { + spinner.isSpinning = false; + }, + }; + return spinner; + }, })); jest.mock('../../../project/publish', () => ({ ...jest.requireActual('../../../project/publish'), @@ -71,6 +89,7 @@ jest.mock('../../../project/publish', () => ({ describe(UpdateRollBackToEmbedded.name, () => { afterEach(() => { vol.reset(); + jest.clearAllMocks(); }); it('errors with both --channel and --branch', async () => { @@ -112,6 +131,58 @@ 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', + '--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/commands/update/index.ts b/packages/eas-cli/src/commands/update/index.ts index 8b960316f5..d139e6a41d 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,25 @@ export default class UpdatePublish extends EasCommand { }; } ); + const updateGroupsToPublish = await resolveUpdateGroupsSupersedingActiveRolloutsAsync( + graphqlClient, + updateGroups, + { + appId: projectId, + branchName: branch.name, + nonInteractive, + forceEndActiveRollout, + rolloutPercentage, + } + ); + 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 +797,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..0f7a784f47 --- /dev/null +++ b/packages/eas-cli/src/update/__tests__/active-rollout-test.ts @@ -0,0 +1,260 @@ +import chalk from 'chalk'; + +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 Log from '../../log'; +import { confirmAsync } from '../../prompts'; + +jest.mock('../../graphql/queries/UpdateQuery'); +jest.mock('../../prompts'); +jest.mock('../../log'); + +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, + rolloutControlUpdate: { id: 'update-control', group: 'group-control-1234' }, +}; + +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(); + jest.mocked(Log.warn).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 rolling out a new update 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('Cannot start a rollout while another rollout is in progress'); + expect(confirmAsync).not.toHaveBeenCalled(); + }); + + 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(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 ', + ]); + }); + + 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('leaves the message and control cells empty 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(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 () => { + 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..e3d12bd93d --- /dev/null +++ b/packages/eas-cli/src/update/active-rollout.ts @@ -0,0 +1,151 @@ +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 { appPlatformDisplayNames } from '../platform'; +import { confirmAsync } from '../prompts'; +import renderTextTable from '../utils/renderTextTable'; + +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; + } + + for (const [index, activeRollouts] of activeRolloutsPerUpdateGroup.entries()) { + if (activeRollouts.length === 0) { + continue; + } + + Log.warn(`A rollout is in progress for runtime version ${updateGroups[index].runtimeVersion}:`); + + const rows = activeRollouts + .map(({ platform, update }) => ({ + platformName: appPlatformDisplayNames[updatePublishPlatformToAppPlatform[platform]], + update, + })) + .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) + ); + } + + if (rolloutPercentage !== undefined) { + throw new Error( + '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.' + ); + } + + Log.warn( + '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 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.' + ); + } + + 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..b1417fa947 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; @@ -119,7 +122,7 @@ export async function republishAsync({ ); } - const publishIndicator = ora('Republishing...').start(); + const publishIndicator = ora('Republishing...'); let updatesRepublished: Awaited>; try { @@ -166,7 +169,7 @@ export async function republishAsync({ : null, }; - updatesRepublished = await PublishMutation.publishUpdateGroupAsync(graphqlClient, [ + const updateGroups = [ { branchId: targetBranchId, runtimeVersion, @@ -179,7 +182,23 @@ export async function republishAsync({ manifestHostOverride: updatesToPublish[0].manifestHostOverride, assetHostOverride: updatesToPublish[0].assetHostOverride, }, - ]); + ]; + + 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, + updateGroupsToPublish + ); if (codeSigningInfo) { Log.log('๐Ÿ”’ Signing republished update group'); @@ -222,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 673c635dae..d9ea33f280 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'; @@ -8,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, @@ -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( @@ -58,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, @@ -67,10 +70,16 @@ export async function publishRollBackToEmbeddedUpdateAsync({ codeSigningInfo, runtimeToPlatformsAndFingerprintInfoMapping, platforms, + 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; } @@ -127,6 +136,10 @@ async function publishRollbacksAsync({ codeSigningInfo, runtimeToPlatformsAndFingerprintInfoMapping, platforms, + projectId, + branchName, + activeRollout, + publishSpinner, }: { graphqlClient: ExpoGraphqlClient; updateMessage: string | undefined; @@ -136,6 +149,10 @@ async function publishRollbacksAsync({ platforms: UpdatePublishPlatform[]; })[]; platforms: UpdatePublishPlatform[]; + projectId: string; + branchName: string; + activeRollout?: { forceEndActiveRollout: boolean; nonInteractive: boolean }; + publishSpinner: Ora; }): Promise { const rollbackInfoGroups = Object.fromEntries(platforms.map(platform => [platform, true])); @@ -156,7 +173,20 @@ async function publishRollbacksAsync({ } ); - const newUpdates = await PublishMutation.publishUpdateGroupAsync(graphqlClient, updateGroups); + 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, + updateGroupsToPublish + ); if (codeSigningInfo) { Log.log('๐Ÿ”’ Signing roll back');