Skip to content
Draft
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 59 additions & 3 deletions packages/eas-cli/src/commands/update/__tests__/republish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
getManifestBodyAsync,
signBody,
} from '../../../utils/code-signing';
import { republishAsync } from '../../../update/republish';
import UpdateRepublish from '../republish';

const projectRoot = '/test-project';
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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<ExpoGraphqlClient>({})),
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'];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'),
Expand All @@ -71,6 +89,7 @@ jest.mock('../../../project/publish', () => ({
describe(UpdateRollBackToEmbedded.name, () => {
afterEach(() => {
vol.reset();
jest.clearAllMocks();
});

it('errors with both --channel and --branch', async () => {
Expand Down Expand Up @@ -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<ExpoGraphqlClient>({})),
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',
Expand Down
22 changes: 22 additions & 0 deletions packages/eas-cli/src/commands/update/__tests__/rollback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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();
Expand Down
27 changes: 26 additions & 1 deletion packages/eas-cli/src/commands/update/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -126,6 +128,7 @@ type UpdateFlags = {
privateKeyPath?: string;
emitMetadata: boolean;
rolloutPercentage?: number;
forceEndActiveRollout: boolean;
json: boolean;
nonInteractive: boolean;
environment?: string;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -228,6 +236,7 @@ export default class UpdatePublish extends EasCommand {
branchName: branchNameArg,
emitMetadata,
rolloutPercentage,
forceEndActiveRollout,
environment: environmentFromFlags,
} = this.sanitizeFlags(rawFlags);

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions packages/eas-cli/src/commands/update/republish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -44,6 +45,7 @@ type UpdateRepublishFlags = {
updateMessage?: string;
platform: Platform[];
privateKeyPath?: string;
forceEndActiveRollout: boolean;
nonInteractive: boolean;
json: boolean;
rolloutPercentage?: number;
Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -177,6 +184,10 @@ export default class UpdateRepublish extends EasCommand {
codeSigningInfo,
json: flags.json,
rolloutPercentage: flags.rolloutPercentage,
activeRollout: {
forceEndActiveRollout: flags.forceEndActiveRollout,
nonInteractive: flags.nonInteractive,
},
});
}

Expand Down Expand Up @@ -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,
};
Expand Down
Loading
Loading