Skip to content

Commit 0e947c5

Browse files
feat: add rollback command for instantly rolling back to previous deployments
1 parent 2bb887a commit 0e947c5

6 files changed

Lines changed: 817 additions & 1 deletion

File tree

.talismanrc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,8 @@ fileignoreconfig:
66
checksum: 9db6c02ad35a0367343cd753b916dd64db4a9efd24838201d2e1113ed19c9b62
77
- filename: package-lock.json
88
checksum: 43c0eecc2192095c8fb5bc524b7dafa33a6141ddd3923d41ffb15ec025bea9a9
9+
- filename: src/commands/launch/rollback.test.ts
10+
checksum: a1010882456f315a918afe2777f90472985e9966bd308c5311ac0de318b14e8c
11+
- filename: test/unit/commands/rollback.test.ts
12+
checksum: d1f931f2d9a397131409399ad6463653e28b5a2224e870b641d9ba57c4418f18
913
version: "1.0"
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import Rollback from './rollback';
2+
import { Logger } from '../../util';
3+
import { cliux } from '@contentstack/cli-utilities';
4+
5+
jest.mock('../../util', () => {
6+
const actual = jest.requireActual('../../util');
7+
return {
8+
...actual,
9+
Logger: jest.fn(),
10+
selectOrg: jest.fn(),
11+
selectProject: jest.fn(),
12+
};
13+
});
14+
15+
jest.mock('@contentstack/cli-utilities', () => {
16+
const actual = jest.requireActual('@contentstack/cli-utilities');
17+
return {
18+
...actual,
19+
configHandler: {
20+
get: jest.fn((key) => {
21+
if (key === 'authtoken') return 'dummy-token';
22+
if (key === 'authorisationType') return 'OAuth';
23+
if (key === 'oauthAccessToken') return 'dummy-oauth-token';
24+
return undefined;
25+
}),
26+
},
27+
cliux: {
28+
...actual.cliux,
29+
inquire: jest.fn(),
30+
print: jest.fn(),
31+
},
32+
};
33+
});
34+
35+
const targetDeployment = {
36+
uid: 'target-uid',
37+
status: 'ARCHIVED',
38+
gitBranch: 'main',
39+
commitHash: 'abcdef1',
40+
createdAt: '2026-04-29T00:00:00Z',
41+
commitMessage: 'previous good build',
42+
deploymentUrl: 'https://example.com',
43+
deploymentNumber: 2,
44+
isRollbackEligible: true,
45+
};
46+
47+
const liveDeployment = {
48+
...targetDeployment,
49+
uid: 'live-uid',
50+
status: 'LIVE',
51+
deploymentNumber: 3,
52+
};
53+
54+
const environmentsResponse = {
55+
data: {
56+
Environments: {
57+
edges: [
58+
{
59+
node: {
60+
uid: 'env-uid',
61+
name: 'Default',
62+
deployments: {
63+
edges: [
64+
{ node: liveDeployment },
65+
{ node: targetDeployment },
66+
],
67+
},
68+
},
69+
},
70+
],
71+
},
72+
},
73+
};
74+
75+
const buildCommand = (flags: Record<string, any> = {}, queryImpl?: jest.Mock, mutateImpl?: jest.Mock) => {
76+
const cmd = new Rollback([], {} as any);
77+
(cmd as any).flags = flags;
78+
(cmd as any).log = jest.fn();
79+
(cmd as any).logger = { log: jest.fn() };
80+
(cmd as any).sharedConfig = { currentConfig: { uid: 'project-uid' } };
81+
(cmd as any).apolloClient = {
82+
query: queryImpl || jest.fn(),
83+
mutate: mutateImpl || jest.fn(),
84+
};
85+
return cmd;
86+
};
87+
88+
describe('Rollback Command', () => {
89+
let exitMock: jest.SpyInstance;
90+
91+
beforeEach(() => {
92+
(Logger as jest.Mock).mockImplementation(() => ({ log: jest.fn() }));
93+
exitMock = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
94+
throw new Error(`process.exit:${code}`);
95+
}) as any);
96+
});
97+
98+
afterEach(() => {
99+
jest.clearAllMocks();
100+
});
101+
102+
it('exits when no rollback-eligible deployments are available', async () => {
103+
const noEligibleResponse = {
104+
data: {
105+
Environments: {
106+
edges: [
107+
{
108+
node: {
109+
uid: 'env-uid',
110+
name: 'Default',
111+
deployments: { edges: [{ node: liveDeployment }] },
112+
},
113+
},
114+
],
115+
},
116+
},
117+
};
118+
const query = jest.fn().mockResolvedValueOnce(noEligibleResponse);
119+
const mutate = jest.fn();
120+
const cmd = buildCommand({ environment: 'Default' }, query, mutate);
121+
jest
122+
.spyOn(cmd as any, 'fetchCurrentLiveDeployment')
123+
.mockResolvedValueOnce(liveDeployment);
124+
125+
await expect((cmd as any).rollbackDeployment()).rejects.toThrow('process.exit:1');
126+
127+
expect(mutate).not.toHaveBeenCalled();
128+
expect(exitMock).toHaveBeenCalledWith(1);
129+
expect((cmd as any).log).toHaveBeenCalledWith(
130+
'No rollback-eligible deployments are available for this environment.',
131+
'error',
132+
);
133+
});
134+
135+
it('exits when --deployment flag does not match an eligible deployment', async () => {
136+
const query = jest.fn().mockResolvedValueOnce(environmentsResponse);
137+
const mutate = jest.fn();
138+
const cmd = buildCommand(
139+
{ environment: 'Default', deployment: 'unknown-uid' },
140+
query,
141+
mutate,
142+
);
143+
jest
144+
.spyOn(cmd as any, 'fetchCurrentLiveDeployment')
145+
.mockResolvedValueOnce(liveDeployment);
146+
147+
await expect((cmd as any).rollbackDeployment()).rejects.toThrow('process.exit:1');
148+
149+
expect(mutate).not.toHaveBeenCalled();
150+
expect(exitMock).toHaveBeenCalledWith(1);
151+
expect((cmd as any).log).toHaveBeenCalledWith(
152+
'Provided deployment UID is not rollback-eligible or does not exist.',
153+
'error',
154+
);
155+
});
156+
157+
it('skips the mutation when the user does not confirm', async () => {
158+
const query = jest.fn().mockResolvedValueOnce(environmentsResponse);
159+
const mutate = jest.fn();
160+
const cmd = buildCommand(
161+
{ environment: 'Default', deployment: 'target-uid', reason: 'audit' },
162+
query,
163+
mutate,
164+
);
165+
jest
166+
.spyOn(cmd as any, 'fetchCurrentLiveDeployment')
167+
.mockResolvedValueOnce(liveDeployment);
168+
(cliux.inquire as jest.Mock).mockResolvedValueOnce(false); // confirm prompt
169+
170+
await (cmd as any).rollbackDeployment();
171+
172+
expect(mutate).not.toHaveBeenCalled();
173+
});
174+
175+
it('fires the rollback mutation and prints the success message', async () => {
176+
const query = jest.fn().mockResolvedValueOnce(environmentsResponse);
177+
const mutate = jest.fn().mockResolvedValueOnce({
178+
data: {
179+
rollbackDeployment: { status: 'PENDING', environmentUid: 'env-uid' },
180+
},
181+
});
182+
const cmd = buildCommand(
183+
{ environment: 'Default', deployment: 'target-uid', reason: 'restoring' },
184+
query,
185+
mutate,
186+
);
187+
jest
188+
.spyOn(cmd as any, 'fetchCurrentLiveDeployment')
189+
.mockResolvedValueOnce(liveDeployment);
190+
(cliux.inquire as jest.Mock).mockResolvedValueOnce(true);
191+
192+
await (cmd as any).rollbackDeployment();
193+
194+
expect(mutate).toHaveBeenCalledTimes(1);
195+
const variables = mutate.mock.calls[0][0].variables;
196+
expect(variables).toEqual({
197+
input: {
198+
deployment: 'target-uid',
199+
environment: 'env-uid',
200+
reason: 'restoring',
201+
},
202+
});
203+
expect(exitMock).not.toHaveBeenCalled();
204+
});
205+
206+
it('logs an error and exits when the rollback mutation fails', async () => {
207+
const query = jest.fn().mockResolvedValueOnce(environmentsResponse);
208+
const error = Object.assign(new Error('boom'), {
209+
graphQLErrors: [{ extensions: { exception: { name: 'DeploymentRollbackFailed' } } }],
210+
});
211+
const mutate = jest.fn().mockRejectedValueOnce(error);
212+
const cmd = buildCommand(
213+
{ environment: 'Default', deployment: 'target-uid' },
214+
query,
215+
mutate,
216+
);
217+
jest
218+
.spyOn(cmd as any, 'fetchCurrentLiveDeployment')
219+
.mockResolvedValueOnce(liveDeployment);
220+
(cliux.inquire as jest.Mock)
221+
.mockResolvedValueOnce('') // reason
222+
.mockResolvedValueOnce(true); // confirm
223+
224+
await expect((cmd as any).rollbackDeployment()).rejects.toThrow('process.exit:1');
225+
226+
expect(mutate).toHaveBeenCalledTimes(1);
227+
expect(exitMock).toHaveBeenCalledWith(1);
228+
expect((cmd as any).log).toHaveBeenCalledWith(
229+
'Rollback failed. Please try again. (DeploymentRollbackFailed)',
230+
'error',
231+
);
232+
});
233+
});

0 commit comments

Comments
 (0)