Skip to content

Commit 795ae02

Browse files
authored
Merge pull request #374 from github/deployment-confirmation
Deployment Confirmation
2 parents ead38ee + d19bcba commit 795ae02

13 files changed

+967
-4
lines changed

README.md

+2
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,8 @@ As seen above, we have two steps. One for a noop deploy, and one for a regular d
301301
| `enforced_deployment_order` | `false` | `""` | A comma separated list of environments that must be deployed in a specific order. Example: `"development,staging,production"`. If this is set then you cannot deploy to latter environments unless the former ones have a successful and active deployment on the latest commit first - See the [enforced deployment order docs](./docs/enforced-deployment-order.md) for more details |
302302
| `use_security_warnings` | `false` | `"true"` | Whether or not to leave security related warnings in log messages during deployments. Default is `"true"` |
303303
| `allow_non_default_target_branch_deployments` | `false` | `"false"` | Whether or not to allow deployments of pull requests that target a branch other than the default branch (aka stable branch) as their merge target. By default, this Action would reject the deployment of a branch named `feature-branch` if it was targeting `foo` instead of `main` (or whatever your default branch is). This option allows you to override that behavior and be able to deploy any branch in your repository regardless of the target branch. This option is potentially unsafe and should be used with caution as most default branches contain branch protection rules. Often times non-default branches do not contain these same branch protection rules. Follow along in this [issue thread](https://github.com/github/branch-deploy/issues/340) to learn more. |
304+
| `deployment_confirmation` | `false` | `"false"` | Whether or not to require an additional confirmation before a deployment can continue. Default is `"false"`. If your project requires elevated security, it is highly recommended to enable this option - especially in open source projects where you might be deploying forks. |
305+
| `deployment_confirmation_timeout` | `false` | `60` | The number of seconds to wait for a deployment confirmation before timing out. Default is `60` seconds (1 minute). |
304306

305307
## Outputs 📤
306308

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,383 @@
1+
import * as core from '@actions/core'
2+
import {COLORS} from '../../src/functions/colors'
3+
import {deploymentConfirmation} from '../../src/functions/deployment-confirmation'
4+
import {API_HEADERS} from '../../src/functions/api-headers'
5+
6+
var context
7+
var octokit
8+
var data
9+
10+
beforeEach(() => {
11+
jest.clearAllMocks()
12+
13+
jest.spyOn(core, 'info').mockImplementation(() => {})
14+
jest.spyOn(core, 'debug').mockImplementation(() => {})
15+
jest.spyOn(core, 'warning').mockImplementation(() => {})
16+
jest.spyOn(core, 'setFailed').mockImplementation(() => {})
17+
18+
// Mock setTimeout to execute immediately
19+
jest.spyOn(global, 'setTimeout').mockImplementation(fn => fn())
20+
21+
// Mock Date.now to control time progression
22+
const mockDate = new Date('2024-10-21T19:11:18Z').getTime()
23+
jest.spyOn(Date, 'now').mockReturnValue(mockDate)
24+
25+
process.env.GITHUB_SERVER_URL = 'https://github.com'
26+
process.env.GITHUB_RUN_ID = '12345'
27+
28+
context = {
29+
actor: 'monalisa',
30+
repo: {
31+
owner: 'corp',
32+
repo: 'test'
33+
},
34+
issue: {
35+
number: 1
36+
},
37+
payload: {
38+
comment: {
39+
body: '.deploy',
40+
id: 123,
41+
user: {
42+
login: 'monalisa'
43+
},
44+
created_at: '2024-10-21T19:11:18Z',
45+
updated_at: '2024-10-21T19:11:18Z',
46+
html_url:
47+
'https://github.com/corp/test/pull/123#issuecomment-1231231231'
48+
}
49+
}
50+
}
51+
52+
octokit = {
53+
rest: {
54+
reactions: {
55+
createForIssueComment: jest.fn().mockResolvedValue({
56+
data: {}
57+
}),
58+
listForIssueComment: jest.fn().mockResolvedValue({
59+
data: []
60+
})
61+
},
62+
issues: {
63+
createComment: jest.fn().mockResolvedValue({
64+
data: {
65+
id: 124
66+
}
67+
}),
68+
updateComment: jest.fn().mockResolvedValue({
69+
data: {}
70+
})
71+
}
72+
}
73+
}
74+
75+
data = {
76+
deployment_confirmation_timeout: 60,
77+
deploymentType: 'branch',
78+
environment: 'production',
79+
environmentUrl: 'https://example.com',
80+
log_url: 'https://github.com/corp/test/actions/runs/12345',
81+
ref: 'cool-branch',
82+
sha: 'abc123',
83+
isVerified: true,
84+
noopMode: false,
85+
isFork: false,
86+
body: '.deploy',
87+
params: 'param1=1,param2=2',
88+
parsed_params: {
89+
param1: '1',
90+
param2: '2'
91+
}
92+
}
93+
})
94+
95+
test('successfully prompts for deployment confirmation and gets confirmed by the original actor', async () => {
96+
// Mock that the user adds a +1 reaction
97+
octokit.rest.reactions.listForIssueComment.mockResolvedValueOnce({
98+
data: [
99+
{
100+
user: {login: 'monalisa'},
101+
content: '+1'
102+
}
103+
]
104+
})
105+
106+
const result = await deploymentConfirmation(context, octokit, data)
107+
108+
expect(result).toBe(true)
109+
expect(octokit.rest.issues.createComment).toHaveBeenCalledWith({
110+
body: expect.stringContaining('Deployment Confirmation Required'),
111+
issue_number: 1,
112+
owner: 'corp',
113+
repo: 'test',
114+
headers: API_HEADERS
115+
})
116+
expect(core.debug).toHaveBeenCalledWith(
117+
'deployment confirmation comment id: 124'
118+
)
119+
expect(core.info).toHaveBeenCalledWith(
120+
`🕒 waiting ${COLORS.highlight}60${COLORS.reset} seconds for deployment confirmation`
121+
)
122+
expect(core.info).toHaveBeenCalledWith(
123+
`✅ deployment confirmed by ${COLORS.highlight}monalisa${COLORS.reset} - sha: ${COLORS.highlight}abc123${COLORS.reset}`
124+
)
125+
126+
expect(octokit.rest.reactions.listForIssueComment).toHaveBeenCalledWith({
127+
comment_id: 124,
128+
owner: 'corp',
129+
repo: 'test',
130+
headers: API_HEADERS
131+
})
132+
133+
expect(octokit.rest.issues.updateComment).toHaveBeenCalledWith({
134+
body: expect.stringContaining('✅ Deployment confirmed by __monalisa__'),
135+
comment_id: 124,
136+
owner: 'corp',
137+
repo: 'test',
138+
headers: API_HEADERS
139+
})
140+
})
141+
142+
test('successfully prompts for deployment confirmation and gets confirmed by the original actor with some null data params in the issue comment', async () => {
143+
data.params = null
144+
data.parsed_params = null
145+
data.environmentUrl = null
146+
147+
// Mock that the user adds a +1 reaction
148+
octokit.rest.reactions.listForIssueComment.mockResolvedValueOnce({
149+
data: [
150+
{
151+
user: {login: 'monalisa'},
152+
content: '+1'
153+
}
154+
]
155+
})
156+
157+
const result = await deploymentConfirmation(context, octokit, data)
158+
159+
expect(result).toBe(true)
160+
expect(octokit.rest.issues.createComment).toHaveBeenCalledWith({
161+
body: expect.stringContaining('"url": null'),
162+
issue_number: 1,
163+
owner: 'corp',
164+
repo: 'test',
165+
headers: API_HEADERS
166+
})
167+
expect(core.debug).toHaveBeenCalledWith(
168+
'deployment confirmation comment id: 124'
169+
)
170+
expect(core.info).toHaveBeenCalledWith(
171+
`🕒 waiting ${COLORS.highlight}60${COLORS.reset} seconds for deployment confirmation`
172+
)
173+
expect(core.info).toHaveBeenCalledWith(
174+
`✅ deployment confirmed by ${COLORS.highlight}monalisa${COLORS.reset} - sha: ${COLORS.highlight}abc123${COLORS.reset}`
175+
)
176+
177+
expect(octokit.rest.reactions.listForIssueComment).toHaveBeenCalledWith({
178+
comment_id: 124,
179+
owner: 'corp',
180+
repo: 'test',
181+
headers: API_HEADERS
182+
})
183+
184+
expect(octokit.rest.issues.updateComment).toHaveBeenCalledWith({
185+
body: expect.stringContaining('✅ Deployment confirmed by __monalisa__'),
186+
comment_id: 124,
187+
owner: 'corp',
188+
repo: 'test',
189+
headers: API_HEADERS
190+
})
191+
})
192+
193+
test('user rejects the deployment with thumbs down', async () => {
194+
// Mock that the user adds a -1 reaction
195+
octokit.rest.reactions.listForIssueComment.mockResolvedValueOnce({
196+
data: [
197+
{
198+
user: {login: 'monalisa'},
199+
content: '-1'
200+
}
201+
]
202+
})
203+
204+
const result = await deploymentConfirmation(context, octokit, data)
205+
206+
expect(result).toBe(false)
207+
expect(octokit.rest.issues.createComment).toHaveBeenCalled()
208+
expect(octokit.rest.reactions.listForIssueComment).toHaveBeenCalled()
209+
210+
expect(octokit.rest.issues.updateComment).toHaveBeenCalledWith({
211+
body: expect.stringContaining('❌ Deployment rejected by __monalisa__'),
212+
comment_id: 124,
213+
owner: 'corp',
214+
repo: 'test',
215+
headers: API_HEADERS
216+
})
217+
218+
expect(core.setFailed).toHaveBeenCalledWith(
219+
`❌ deployment rejected by ${COLORS.highlight}monalisa${COLORS.reset}`
220+
)
221+
})
222+
223+
test('deployment confirmation times out after no response', async () => {
224+
// Mock empty reactions list (no user reaction)
225+
octokit.rest.reactions.listForIssueComment.mockResolvedValue({
226+
data: []
227+
})
228+
229+
// Mock Date.now to first return start time, then timeout
230+
Date.now
231+
.mockReturnValueOnce(new Date('2024-10-21T19:11:18Z').getTime()) // Start time
232+
.mockReturnValue(new Date('2024-10-21T19:12:30Z').getTime()) // After timeout
233+
234+
const result = await deploymentConfirmation(context, octokit, data)
235+
236+
expect(result).toBe(false)
237+
expect(octokit.rest.issues.createComment).toHaveBeenCalled()
238+
239+
expect(octokit.rest.issues.updateComment).toHaveBeenCalledWith({
240+
body: expect.stringContaining('⏱️ Deployment confirmation timed out'),
241+
comment_id: 124,
242+
owner: 'corp',
243+
repo: 'test',
244+
headers: API_HEADERS
245+
})
246+
247+
expect(core.setFailed).toHaveBeenCalledWith(
248+
`⏱️ deployment confirmation timed out after ${COLORS.highlight}60${COLORS.reset} seconds`
249+
)
250+
})
251+
252+
test('ignores reactions from other users', async () => {
253+
// First call returns reactions from other users
254+
octokit.rest.reactions.listForIssueComment.mockResolvedValueOnce({
255+
data: [
256+
{
257+
user: {login: 'other-user'},
258+
content: '+1'
259+
}
260+
]
261+
})
262+
263+
// Second call includes the original actor's reaction
264+
octokit.rest.reactions.listForIssueComment.mockResolvedValueOnce({
265+
data: [
266+
{
267+
user: {login: 'other-user'},
268+
content: '+1'
269+
},
270+
{
271+
user: {login: 'monalisa'},
272+
content: '+1'
273+
}
274+
]
275+
})
276+
277+
const result = await deploymentConfirmation(context, octokit, data)
278+
279+
expect(result).toBe(true)
280+
expect(octokit.rest.reactions.listForIssueComment).toHaveBeenCalledTimes(2)
281+
expect(octokit.rest.issues.updateComment).toHaveBeenCalledWith({
282+
body: expect.stringContaining('✅ Deployment confirmed by __monalisa__'),
283+
comment_id: 124,
284+
owner: 'corp',
285+
repo: 'test',
286+
headers: API_HEADERS
287+
})
288+
expect(core.debug).toHaveBeenCalledWith(
289+
'ignoring reaction from other-user, expected monalisa'
290+
)
291+
expect(core.info).toHaveBeenCalledWith(
292+
`✅ deployment confirmed by ${COLORS.highlight}monalisa${COLORS.reset} - sha: ${COLORS.highlight}abc123${COLORS.reset}`
293+
)
294+
})
295+
296+
test('ignores non thumbsUp/thumbsDown reactions from the original actor', async () => {
297+
// Mock reactions list with various reaction types from original actor
298+
octokit.rest.reactions.listForIssueComment.mockResolvedValueOnce({
299+
data: [
300+
{
301+
user: {login: 'monalisa'},
302+
content: 'confused'
303+
},
304+
{
305+
user: {login: 'monalisa'},
306+
content: 'eyes'
307+
},
308+
{
309+
user: {login: 'monalisa'},
310+
content: 'rocket'
311+
}
312+
]
313+
})
314+
315+
// Add a thumbs up in the second poll
316+
octokit.rest.reactions.listForIssueComment.mockResolvedValueOnce({
317+
data: [
318+
{
319+
user: {login: 'monalisa'},
320+
content: 'confused'
321+
},
322+
{
323+
user: {login: 'monalisa'},
324+
content: 'eyes'
325+
},
326+
{
327+
user: {login: 'monalisa'},
328+
content: 'rocket'
329+
},
330+
{
331+
user: {login: 'monalisa'},
332+
content: '+1'
333+
}
334+
]
335+
})
336+
337+
const result = await deploymentConfirmation(context, octokit, data)
338+
339+
expect(result).toBe(true)
340+
expect(octokit.rest.reactions.listForIssueComment).toHaveBeenCalledTimes(2)
341+
342+
// Verify that debug was called for each ignored reaction type
343+
expect(core.debug).toHaveBeenCalledWith('ignoring reaction: confused')
344+
expect(core.debug).toHaveBeenCalledWith('ignoring reaction: eyes')
345+
expect(core.debug).toHaveBeenCalledWith('ignoring reaction: rocket')
346+
347+
// Verify final confirmation happened
348+
expect(octokit.rest.issues.updateComment).toHaveBeenCalledWith({
349+
body: expect.stringContaining('✅ Deployment confirmed by __monalisa__'),
350+
comment_id: 124,
351+
owner: 'corp',
352+
repo: 'test',
353+
headers: API_HEADERS
354+
})
355+
})
356+
357+
test('handles API errors gracefully', async () => {
358+
// First call throws error
359+
octokit.rest.reactions.listForIssueComment.mockRejectedValueOnce(
360+
new Error('API error')
361+
)
362+
363+
// Second call succeeds with valid reaction
364+
octokit.rest.reactions.listForIssueComment.mockResolvedValueOnce({
365+
data: [
366+
{
367+
user: {login: 'monalisa'},
368+
content: '+1'
369+
}
370+
]
371+
})
372+
373+
const result = await deploymentConfirmation(context, octokit, data)
374+
375+
expect(result).toBe(true)
376+
expect(core.warning).toHaveBeenCalledWith(
377+
'temporary failure when checking for reactions on the deployment confirmation comment: API error'
378+
)
379+
expect(octokit.rest.reactions.listForIssueComment).toHaveBeenCalledTimes(2)
380+
expect(core.info).toHaveBeenCalledWith(
381+
`✅ deployment confirmed by ${COLORS.highlight}monalisa${COLORS.reset} - sha: ${COLORS.highlight}abc123${COLORS.reset}`
382+
)
383+
})

0 commit comments

Comments
 (0)