Skip to content

Commit 70166c1

Browse files
authored
Merge pull request #13 from github/environment-specific-locks
Environment Specific Locks 🌍 🔒
2 parents dbb0ca5 + 5ba1402 commit 70166c1

19 files changed

+3396
-942
lines changed

.node-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
18.7.0
1+
18.9.0

README.md

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,27 +25,35 @@ This section goes into detail on how you can use this Action in your own workflo
2525

2626
| Input | Required? | Default | Description |
2727
| ----- | --------- | ------- | ----------- |
28-
| github_token | yes | ${{ github.token }} | The GitHub token used to create an authenticated client - Provided for you by default! |
29-
| reaction | no | eyes | If set, the specified emoji "reaction" is put on the comment to indicate that the trigger was detected. For example, "rocket" or "eyes" |
30-
| lock_trigger | no | .lock | The string to look for in comments as an IssueOps lock trigger. Used for locking branch deployments on a specific branch. Example: "lock" |
31-
| unlock_trigger | no | .unlock | The string to look for in comments as an IssueOps unlock trigger. Used for unlocking branch deployments. Example: "unlock" |
32-
| lock_info_alias | no | .wcid | An alias or shortcut to get details about the current lock (if it exists) Example: ".info" - Hubbers will find the ".wcid" default helpful ("where can I deploy") |
33-
| prefix_only | no | true | If "false", the trigger can match anywhere in the comment |
34-
| mode | no | - | The mode to use "lock", "unlock", or "check". If not provided, the default mode assumes the workflow is not headless and triggered by a comment on a pull request - Example: .lock / .unlock
28+
| `github_token` | `true` | `${{ github.token }}` | The GitHub token used to create an authenticated client - Provided for you by default! |
29+
| `environment` | `true` | `"production"` | The explict environment to apply locking actions to when running in headless mode - OR the default environment to use when running in the context of IssueOps commands in a comment - Examples: production, development, etc - Use "global" for the global lock environment |
30+
| `environment_targets` | `false` | `"production,development,staging"` | Optional (or additional) target environments to select for use with lock/unlock. Example, "production,development,staging". Example usage: `.lock development`, `.lock production`, `.unlock staging` |
31+
| `reaction` | `false` | `eyes` | If set, the specified emoji "reaction" is put on the comment to indicate that the trigger was detected. For example, "rocket" or "eyes" |
32+
| `lock_trigger` | `false` | `.lock` | The string to look for in comments as an IssueOps lock trigger. Used for locking branch deployments on a specific branch. Example: "lock" |
33+
| `unlock_trigger` | `false` | `.unlock` | The string to look for in comments as an IssueOps unlock trigger. Used for unlocking branch deployments. Example: "unlock" |
34+
| `lock_info_alias` | `false` | `.wcid` | An alias or shortcut to get details about the current lock (if it exists) Example: ".info" - Hubbers will find the ".wcid" default helpful ("where can I deploy") |
35+
| `global_lock_flag` | `false` | `--global` | The flag to pass into the lock command to lock all environments on IssueOps commands in comments. Example: "--global" |
36+
| `prefix_only` | `false` | `"true"` | If "false", the trigger can match anywhere in the comment |
37+
| `mode` | `false` | - | The mode to use "lock", "unlock", or "check". If not provided, the default mode assumes the workflow is not headless and triggered by a comment on a pull request - Example: .lock / .unlock
3538

3639
### About the `mode` Input
3740

38-
If you wish to use this Action via a comment on a pull request, simply omit the `mode` input. If you wish to use this Action via a workflow dispatch event, conditially in a custom workflow, or otherwise, you must provide the `mode` input. You are telling the Action what "mode" to use. The `mode` input can be either `lock` or `unlock`.
41+
If you wish to use this Action via a comment on a pull request, simply omit the `mode` input. If you wish to use this Action via a workflow dispatch event, conditially in a custom workflow, or otherwise, you must provide the `mode` input. You are telling the Action what "mode" to use. The `mode` input can be either `lock`, `unlock`, or `check`.
3942

4043
## Outputs 📤
4144

4245
| Output | Description |
4346
| ------ | ----------- |
44-
| triggered | The string "true" if the trigger was found, otherwise the string "false" |
45-
| comment_id | The comment id which triggered this deployment |
46-
| headless | The string "true" if the run was headless, otherwise the string "false" - Headless in this context would be if the "mode" was set and the Action was not invoked by a comment on a pull request |
47-
| locked | If the 'mode' is set to 'check', this output is exported to show if the lock is set in a headless run |
48-
| branch | If the mode is set to "check", this output will be the branch name that holds the lock, otherwise it will be empty |
47+
| `triggered` | The string "true" if the trigger was found, otherwise the string "false" |
48+
| `comment_id` | The comment id which triggered this deployment |
49+
| type | The type of trigger which was found - 'lock', 'unlock', or 'info-info-alias' |
50+
| `comment_body` | The comment body which triggered this action (if it was not headless) |
51+
| `headless` | The string "true" if the run was headless, otherwise the string "false" - Headless in this context would be if the "mode" was set and the Action was not invoked by a comment on a pull request |
52+
| `locked` | If the 'mode' is set to 'check', this output is exported to show if the lock is set in a headless run |
53+
| `branch` | If the mode is set to "check", this output will be the branch name that holds the lock, otherwise it will be empty |
54+
| `global_lock_claimed` | The string "true" if the global lock was claimed |
55+
| `global_lock_released` | The string "true" if the global lock was released |
56+
| `lock_environment` | When running in headless mode and the "mode" is set to "check", this output will be the environment name that holds the lock, otherwise it will be empty |
4957

5058
## Examples 📖
5159

@@ -77,14 +85,22 @@ jobs:
7785
### Setting a Lock via a Workflow Dispatch Event
7886
7987
```yaml
80-
name: lock
88+
name: lock-dispatch
8189

8290
on:
8391
workflow_dispatch:
8492
inputs:
8593
reason:
86-
description: 'Reason for claiming the deployment lock for this repository'
94+
description: 'Reason for claiming the deployment lock'
8795
required: false
96+
environment:
97+
description: 'The environment to claim a lock for (production, staging, etc) - global is supported to claim the special global lock)'
98+
required: true
99+
default: 'production'
100+
mode:
101+
description: 'The mode to use: check, lock, unlock'
102+
required: true
103+
default: 'lock'
88104

89105
permissions:
90106
contents: write
@@ -93,12 +109,12 @@ jobs:
93109
lock:
94110
runs-on: ubuntu-latest
95111
steps:
96-
# Lock
97112
- uses: github/[email protected]
98113
id: lock
99114
with:
100-
mode: "lock"
115+
mode: ${{ github.event.inputs.mode }}
101116
reason: ${{ github.event.inputs.reason }}
117+
environment: ${{ github.event.inputs.environment }}
102118
```
103119
104120
### Removing a Lock via a Workflow Dispatch Event

__tests__/functions/check.test.js

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable no-extra-semi */
12
import * as core from '@actions/core'
23
import {check} from '../../src/functions/check'
34

@@ -22,7 +23,10 @@ beforeEach(() => {
2223
})
2324

2425
const lockBase64Octocat =
25-
'ewogICAgInJlYXNvbiI6ICJUZXN0aW5nIG15IG5ldyBmZWF0dXJlIHdpdGggbG90cyBvZiBjYXRzIiwKICAgICJicmFuY2giOiAib2N0b2NhdHMtZXZlcnl3aGVyZSIsCiAgICAiY3JlYXRlZF9hdCI6ICIyMDIyLTA2LTE0VDIxOjEyOjE0LjA0MVoiLAogICAgImNyZWF0ZWRfYnkiOiAib2N0b2NhdCIsCiAgICAic3RpY2t5IjogdHJ1ZSwKICAgICJsaW5rIjogImh0dHBzOi8vZ2l0aHViLmNvbS90ZXN0LW9yZy90ZXN0LXJlcG8vcHVsbC8yI2lzc3VlY29tbWVudC00NTYiCn0K'
26+
'ewogICAgInJlYXNvbiI6ICJUZXN0aW5nIG15IG5ldyBmZWF0dXJlIHdpdGggbG90cyBvZiBjYXRzIiwKICAgICJicmFuY2giOiAib2N0b2NhdHMtZXZlcnl3aGVyZSIsCiAgICAiY3JlYXRlZF9hdCI6ICIyMDIyLTA2LTE0VDIxOjEyOjE0LjA0MVoiLAogICAgImNyZWF0ZWRfYnkiOiAib2N0b2NhdCIsCiAgICAic3RpY2t5IjogdHJ1ZSwKICAgICJlbnZpcm9ubWVudCI6ICJwcm9kdWN0aW9uIiwKICAgICJ1bmxvY2tfY29tbWFuZCI6ICIudW5sb2NrIHByb2R1Y3Rpb24iLAogICAgImdsb2JhbCI6IGZhbHNlLAogICAgImxpbmsiOiAiaHR0cHM6Ly9naXRodWIuY29tL3Rlc3Qtb3JnL3Rlc3QtcmVwby9wdWxsLzIjaXNzdWVjb21tZW50LTQ1NiIKfQo='
27+
28+
const lockBase64OctocatGlobal =
29+
'ewogICAgInJlYXNvbiI6ICJUZXN0aW5nIG15IG5ldyBmZWF0dXJlIHdpdGggbG90cyBvZiBjYXRzIiwKICAgICJicmFuY2giOiAib2N0b2NhdHMtZXZlcnl3aGVyZSIsCiAgICAiY3JlYXRlZF9hdCI6ICIyMDIyLTA2LTE0VDIxOjEyOjE0LjA0MVoiLAogICAgImNyZWF0ZWRfYnkiOiAib2N0b2NhdCIsCiAgICAic3RpY2t5IjogdHJ1ZSwKICAgICJlbnZpcm9ubWVudCI6IG51bGwsCiAgICAidW5sb2NrX2NvbW1hbmQiOiAiLnVubG9jayAtLWdsb2JhbCIsCiAgICAiZ2xvYmFsIjogdHJ1ZSwKICAgICJsaW5rIjogImh0dHBzOi8vZ2l0aHViLmNvbS90ZXN0LW9yZy90ZXN0LXJlcG8vcHVsbC8yI2lzc3VlY29tbWVudC00NTYiCn0K'
2630

2731
const context = {
2832
repo: {
@@ -31,12 +35,15 @@ const context = {
3135
}
3236
}
3337

38+
const environment = 'production'
39+
3440
const octokit = {
3541
rest: {
3642
repos: {
3743
getBranch: jest
3844
.fn()
39-
.mockReturnValueOnce({data: {commit: {sha: 'abc123'}}}),
45+
.mockRejectedValueOnce(new NotFoundError('Reference does not exist')) // no global lock
46+
.mockReturnValueOnce({data: {commit: {sha: 'abc123'}}}), // global lock
4047
get: jest.fn().mockReturnValue({data: {default_branch: 'main'}}),
4148
getContent: jest
4249
.fn()
@@ -45,31 +52,58 @@ const octokit = {
4552
}
4653
}
4754

48-
test('successfully checks for a lock and finds a lock', async () => {
49-
expect(await check(octokit, context)).toBe(true)
55+
test('successfully checks for a production lock and finds a lock', async () => {
56+
expect(await check(octokit, context, environment)).toBe(true)
5057
expect(setOutputMock).toHaveBeenCalledWith('locked', 'true')
51-
expect(infoMock).toHaveBeenCalledWith('lock exists')
58+
expect(setOutputMock).toHaveBeenCalledWith('lock_environment', environment)
59+
expect(infoMock).toHaveBeenCalledWith('global lock does not exist')
60+
expect(infoMock).toHaveBeenCalledWith('production lock exists')
5261
})
5362

54-
test('successfully checks for a lock and does not find a lock branch', async () => {
63+
test('successfully checks for a production lock and does not find a lock branch', async () => {
5564
octokit.rest.repos.getBranch = jest
5665
.fn()
5766
.mockRejectedValueOnce(new NotFoundError('Reference does not exist'))
58-
expect(await check(octokit, context)).toBe(false)
67+
.mockRejectedValueOnce(new NotFoundError('Reference does not exist'))
68+
expect(await check(octokit, context, environment)).toBe(false)
5969
expect(setOutputMock).toHaveBeenCalledWith('locked', 'false')
60-
expect(infoMock).toHaveBeenCalledWith('lock does not exist')
70+
expect(infoMock).toHaveBeenCalledWith('global lock does not exist')
71+
expect(infoMock).toHaveBeenCalledWith('production lock does not exist')
6172
})
6273

63-
test('successfully checks for a lock and does not find a lock file', async () => {
74+
test('successfully checks for a production lock and finds a global lock instead', async () => {
75+
;(octokit.rest.repos.getBranch = jest
76+
.fn()
77+
.mockReturnValueOnce({data: {commit: {sha: 'cba123'}}})), // global lock
78+
(octokit.rest.getContent = jest
79+
.fn()
80+
.mockReturnValue({data: {content: lockBase64OctocatGlobal}}))
81+
expect(await check(octokit, context, environment)).toBe(true)
82+
expect(setOutputMock).toHaveBeenCalledWith('locked', 'true')
83+
expect(setOutputMock).toHaveBeenCalledWith('lock_environment', 'global')
84+
expect(infoMock).toHaveBeenCalledWith('global lock exists')
85+
})
86+
87+
test('successfully checks for a global lock and does not find one', async () => {
88+
;(octokit.rest.repos.getBranch = jest
89+
.fn()
90+
.mockRejectedValueOnce(new NotFoundError('Reference does not exist'))), // global lock
91+
expect(await check(octokit, context, 'global')).toBe(false)
92+
expect(setOutputMock).toHaveBeenCalledWith('locked', 'false')
93+
expect(infoMock).toHaveBeenCalledWith('global lock does not exist')
94+
})
95+
96+
test('successfully checks for a production lock and does not find a lock file', async () => {
6497
octokit.rest.repos.getContent = jest
6598
.fn()
6699
.mockRejectedValueOnce(new NotFoundError('Reference does not exist'))
67-
expect(await check(octokit, context)).toBe(false)
100+
expect(await check(octokit, context, environment)).toBe(false)
68101
expect(setOutputMock).toHaveBeenCalledWith('locked', 'false')
69-
expect(infoMock).toHaveBeenCalledWith('lock does not exist')
102+
expect(infoMock).toHaveBeenCalledWith('global lock does not exist')
103+
expect(infoMock).toHaveBeenCalledWith('production lock does not exist')
70104
})
71105

72-
test('successfully checks for a lock but cannot decode the lock file', async () => {
106+
test('successfully checks for a production lock but cannot decode the lock file', async () => {
73107
octokit.rest.repos.getContent = jest
74108
.fn()
75109
.mockReturnValue({data: {content: undefined}})
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import {environmentTargets} from '../../src/functions/environment-targets'
2+
import * as actionStatus from '../../src/functions/action-status'
3+
import * as core from '@actions/core'
4+
// import dedent from 'dedent-js'
5+
6+
const debugMock = jest.spyOn(core, 'debug').mockImplementation(() => {})
7+
// const warningMock = jest.spyOn(core, 'warning').mockImplementation(() => {})
8+
// const saveStateMock = jest.spyOn(core, 'saveState')
9+
10+
beforeEach(() => {
11+
jest.resetAllMocks()
12+
jest.spyOn(actionStatus, 'actionStatus').mockImplementation(() => {
13+
return undefined
14+
})
15+
jest.spyOn(core, 'warning').mockImplementation(() => {})
16+
process.env.INPUT_ENVIRONMENT_TARGETS = 'production,development,staging'
17+
process.env.INPUT_GLOBAL_LOCK_FLAG = '--global'
18+
process.env.INPUT_LOCK_INFO_ALIAS = '.wcid'
19+
})
20+
21+
const environment = 'production'
22+
23+
test('checks the comment body on a lock request and uses the default environment', async () => {
24+
expect(
25+
await environmentTargets(
26+
environment,
27+
'.lock', // comment body
28+
'.lock', // lock trigger
29+
'.unlock', // unlock trigger
30+
null, // context
31+
null, // octokit
32+
null // reaction_id
33+
)
34+
).toBe('production')
35+
expect(debugMock).toHaveBeenCalledWith(
36+
'Using default environment for lock request'
37+
)
38+
})
39+
40+
test('checks the comment body on an unlock request and uses the default environment', async () => {
41+
expect(
42+
await environmentTargets(
43+
environment,
44+
'.unlock', // comment body
45+
'.lock', // lock trigger
46+
'.unlock', // unlock trigger
47+
null, // context
48+
null, // octokit
49+
null // reaction_id
50+
)
51+
).toBe('production')
52+
expect(debugMock).toHaveBeenCalledWith(
53+
'Using default environment for unlock request'
54+
)
55+
})
56+
57+
test('checks the comment body on a lock info alias request and uses the default environment', async () => {
58+
expect(
59+
await environmentTargets(
60+
environment,
61+
'.wcid', // comment body
62+
'.lock', // lock trigger
63+
'.unlock', // unlock trigger
64+
null, // context
65+
null, // octokit
66+
null // reaction_id
67+
)
68+
).toBe('production')
69+
expect(debugMock).toHaveBeenCalledWith(
70+
'Using default environment for lock info request'
71+
)
72+
})
73+
74+
test('checks the comment body on a lock request and uses the production environment', async () => {
75+
expect(
76+
await environmentTargets(
77+
environment,
78+
'.lock production', // comment body
79+
'.lock', // lock trigger
80+
'.unlock', // unlock trigger
81+
null, // context
82+
null, // octokit
83+
null // reaction_id
84+
)
85+
).toBe('production')
86+
expect(debugMock).toHaveBeenCalledWith(
87+
'Found environment target for lock request: production'
88+
)
89+
})
90+
91+
test('checks the comment body on an unlock request and uses the development environment', async () => {
92+
expect(
93+
await environmentTargets(
94+
environment,
95+
'.unlock development', // comment body
96+
'.lock', // lock trigger
97+
'.unlock', // unlock trigger
98+
null, // context
99+
null, // octokit
100+
null // reaction_id
101+
)
102+
).toBe('development')
103+
expect(debugMock).toHaveBeenCalledWith(
104+
'Found environment target for unlock request: development'
105+
)
106+
})
107+
108+
test('checks the comment body on a lock info alias request and uses the development environment', async () => {
109+
expect(
110+
await environmentTargets(
111+
environment,
112+
'.wcid development', // comment body
113+
'.lock', // lock trigger
114+
'.unlock', // unlock trigger
115+
null, // context
116+
null, // octokit
117+
null // reaction_id
118+
)
119+
).toBe('development')
120+
expect(debugMock).toHaveBeenCalledWith(
121+
'Found environment target for lock info request: development'
122+
)
123+
})
124+
125+
test('checks the comment body on a lock info request and uses the development environment', async () => {
126+
expect(
127+
await environmentTargets(
128+
environment,
129+
'.lock --info development', // comment body
130+
'.lock', // lock trigger
131+
'.unlock', // unlock trigger
132+
null, // context
133+
null, // octokit
134+
null // reaction_id
135+
)
136+
).toBe('development')
137+
expect(debugMock).toHaveBeenCalledWith(
138+
'Found environment target for lock request: development'
139+
)
140+
})
141+
142+
test('checks the comment body on a lock info request and uses the global environment', async () => {
143+
expect(
144+
await environmentTargets(
145+
environment,
146+
'.lock --info --global', // comment body
147+
'.lock', // lock trigger
148+
'.unlock', // unlock trigger
149+
null, // context
150+
null, // octokit
151+
null // reaction_id
152+
)
153+
).toBe('global')
154+
expect(debugMock).toHaveBeenCalledWith(
155+
'Global lock flag found in environment target check'
156+
)
157+
})
158+
159+
test('checks the comment body on a lock info request and uses the development environment (using -d)', async () => {
160+
expect(
161+
await environmentTargets(
162+
environment,
163+
'.lock -d development', // comment body
164+
'.lock', // lock trigger
165+
'.unlock', // unlock trigger
166+
null, // context
167+
null, // octokit
168+
null // reaction_id
169+
)
170+
).toBe('development')
171+
expect(debugMock).toHaveBeenCalledWith(
172+
'Found environment target for lock request: development'
173+
)
174+
})
175+
176+
test('checks the comment body on a lock info request and finds no matching environment', async () => {
177+
const actionStatusSpy = jest.spyOn(actionStatus, 'actionStatus')
178+
expect(
179+
await environmentTargets(
180+
environment,
181+
'.lock -d potato', // comment body
182+
'.lock', // lock trigger
183+
'.unlock', // unlock trigger
184+
null, // context
185+
null, // octokit
186+
null // reaction_id
187+
)
188+
).toBe(false)
189+
expect(actionStatusSpy).toHaveBeenCalledWith(
190+
null,
191+
null,
192+
null,
193+
expect.stringContaining('No matching environment target found')
194+
)
195+
})

0 commit comments

Comments
 (0)