├── .babelrc
├── .eslintignore
├── .eslintrc.json
├── .gitattributes
├── .github
├── CODEOWNERS
├── ISSUE_TEMPLATE
│ └── bug.yml
├── config
│ └── exclude.txt
└── workflows
│ ├── actions-config-validation.yml
│ ├── codeql-analysis.yml
│ ├── lint.yml
│ ├── manual.yml
│ ├── old
│ └── sample-workflow.yml
│ ├── package-check.yml
│ └── test.yml
├── .gitignore
├── .node-version
├── .prettierignore
├── .prettierrc.json
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── __tests__
├── functions
│ ├── actions-status.test.js
│ ├── admin.test.js
│ ├── context-check.test.js
│ ├── deployment.test.js
│ ├── environment-targets.test.js
│ ├── help.test.js
│ ├── identical-commit-check.test.js
│ ├── lock.test.js
│ ├── post-deploy.test.js
│ ├── post.test.js
│ ├── prechecks.test.js
│ ├── react-emote.test.js
│ ├── string-to-array.test.js
│ ├── time-diff.test.js
│ ├── trigger-check.test.js
│ └── unlock.test.js
├── main.test.js
└── schemas
│ └── action.schema.yml
├── action.yml
├── badges
└── coverage.svg
├── dist
├── index.js
├── index.js.map
├── licenses.txt
└── sourcemap-register.js
├── docs
├── assets
│ └── ship-it.jpg
├── custom-deployment-messages.md
├── examples.md
├── locks.md
├── merge-commit-strategy.md
└── usage.md
├── events
├── issue_comment_deploy.json
├── issue_comment_deploy_main.json
└── issue_comment_deploy_noop.json
├── package-lock.json
├── package.json
├── script
└── release
└── src
├── functions
├── action-status.js
├── admin.js
├── context-check.js
├── deployment.js
├── environment-targets.js
├── help.js
├── identical-commit-check.js
├── lock-metadata.js
├── lock.js
├── post-deploy.js
├── post.js
├── prechecks.js
├── react-emote.js
├── string-to-array.js
├── time-diff.js
├── trigger-check.js
├── unlock.js
└── valid-permissions.js
└── main.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "test": {
4 | "plugins": [
5 | "@babel/plugin-transform-modules-commonjs"
6 | ]
7 | }
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | dist/
2 | node_modules/
3 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "commonjs": true,
4 | "es6": true,
5 | "jest": true,
6 | "node": true
7 | },
8 | "extends": "eslint:recommended",
9 | "globals": {
10 | "Atomics": "readonly",
11 | "SharedArrayBuffer": "readonly"
12 | },
13 | "parserOptions": {
14 | "ecmaVersion": 2020,
15 | "sourceType": "module"
16 | },
17 | "rules": {}
18 | }
19 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | dist/** -diff linguist-generated=true
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @GrantBirki
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug.yml:
--------------------------------------------------------------------------------
1 | name: Bug Report
2 | description: File a bug/issue report
3 | labels: ["bug"]
4 | body:
5 | - type: markdown
6 | attributes:
7 | value: |
8 | # Bug Report 🐛
9 |
10 | Thanks for taking the time to fill out this bug report!
11 |
12 | Please answer each question below to your best ability. It is okay to leave questions blank if you have to!
13 |
14 | - type: textarea
15 | id: description
16 | attributes:
17 | label: Describe the Issue
18 | description: Please describe the bug/issue in detail
19 | placeholder: Something is wrong with X when trying to do Y
20 | validations:
21 | required: true
22 |
23 | - type: textarea
24 | id: config
25 | attributes:
26 | label: Action Configuration
27 | description: Please copy and paste your Action's configuration. Please omit any sensitive information if your configuration is not public.
28 | placeholder: |
29 | ```yaml
30 | - name: branch-deploy
31 | id: branch-deploy
32 | uses: github/branch-deploy@vX.X.X
33 | with:
34 | trigger: ".deploy"
35 | environment: "production"
36 | stable_branch: "main"
37 | ```
38 |
39 | - type: textarea
40 | id: logs
41 | attributes:
42 | label: Relevant Actions Log Output
43 | description: Please copy and paste any relevant log output. If your Action's workflow is public, please provide a direct link to the logs
44 |
45 | - type: textarea
46 | id: extra
47 | attributes:
48 | label: Extra Information
49 | description: Any extra information, links to issues, screenshots, etc
50 |
--------------------------------------------------------------------------------
/.github/config/exclude.txt:
--------------------------------------------------------------------------------
1 | # gitignore style exclude file for the GrantBirki/json-yaml-validate Action
2 |
--------------------------------------------------------------------------------
/.github/workflows/actions-config-validation.yml:
--------------------------------------------------------------------------------
1 | name: actions-config-validation
2 | on:
3 | push:
4 | branches:
5 | - main
6 | pull_request:
7 | workflow_dispatch:
8 |
9 | permissions:
10 | contents: read
11 | pull-requests: write # enable write permissions for pull request comments
12 |
13 | jobs:
14 | actions-config-validation:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - uses: actions/checkout@v3
18 |
19 | - name: actions-config-validation
20 | uses: GrantBirki/json-yaml-validate@9a228501b1e80369c582764187fbefdc2f7fbac1 # pin@v1.3.1
21 | with:
22 | comment: "true" # enable comment mode
23 | yaml_schema: "__tests__/schemas/action.schema.yml"
24 | exclude_file: ".github/config/exclude.txt"
25 |
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | name: CodeQL
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | # Disable on PR for now to speed up testing
7 | # pull_request:
8 | # # The branches below must be a subset of the branches above
9 | # branches: [ main ]
10 | schedule:
11 | - cron: '45 3 * * 5'
12 |
13 | jobs:
14 | analyze:
15 | name: Analyze
16 | runs-on: ubuntu-latest
17 | permissions:
18 | actions: read
19 | contents: read
20 | security-events: write
21 |
22 | strategy:
23 | fail-fast: false
24 | matrix:
25 | language: [ 'javascript' ]
26 |
27 | steps:
28 | - name: checkout
29 | uses: actions/checkout@v3
30 |
31 | # Initializes the CodeQL tools for scanning.
32 | - name: Initialize CodeQL
33 | uses: github/codeql-action/init@v2
34 | with:
35 | languages: ${{ matrix.language }}
36 |
37 | - name: Autobuild
38 | uses: github/codeql-action/autobuild@v2
39 |
40 | - name: Perform CodeQL Analysis
41 | uses: github/codeql-action/analyze@v2
42 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: lint
2 | on:
3 | pull_request:
4 | push:
5 | branches:
6 | - main
7 | - 'releases/*'
8 |
9 | permissions:
10 | contents: read
11 |
12 | jobs:
13 | lint:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: actions/checkout@v3
17 |
18 | - name: setup node
19 | uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # pin@v3.6.0
20 | with:
21 | node-version-file: .node-version
22 | cache: 'npm'
23 |
24 | - name: install dependencies
25 | run: npm ci
26 |
27 | - name: lint
28 | run: |
29 | npm run format-check
30 | npm run lint
31 |
--------------------------------------------------------------------------------
/.github/workflows/manual.yml:
--------------------------------------------------------------------------------
1 | # This is a basic workflow that is manually triggered
2 |
3 | name: Manual workflow
4 |
5 | # Controls when the action will run. Workflow runs when manually triggered using the UI
6 | # or API.
7 | on:
8 | workflow_dispatch:
9 | # Inputs the workflow accepts.
10 | inputs:
11 | name:
12 | # Friendly description to be shown in the UI instead of 'name'
13 | description: 'Person to greet'
14 | # Default value if no value is explicitly provided
15 | default: 'World'
16 | # Input has to be provided for the workflow to run
17 | required: true
18 | # The data type of the input
19 | type: string
20 |
21 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel
22 | jobs:
23 | # This workflow contains a single job called "greet"
24 | greet:
25 | # The type of runner that the job will run on
26 | runs-on: ubuntu-latest
27 |
28 | # Steps represent a sequence of tasks that will be executed as part of the job
29 | steps:
30 | # Runs a single command using the runners shell
31 | - name: Send greeting
32 | run: echo "Hello ${{ inputs.name }}"
33 |
--------------------------------------------------------------------------------
/.github/workflows/old/sample-workflow.yml:
--------------------------------------------------------------------------------
1 | # name: "sample-workflow"
2 |
3 | # on:
4 | # issue_comment:
5 | # types: [created]
6 |
7 | # permissions:
8 | # pull-requests: write
9 | # deployments: write
10 | # contents: write
11 | # checks: read
12 |
13 | # jobs:
14 | # sample:
15 | # if: ${{ github.event.issue.pull_request }} # only run on pull request comments
16 | # environment: production-secrets
17 | # runs-on: ubuntu-latest
18 | # steps:
19 | # # Need to checkout for testing the Action in this repo
20 | # - uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # pin@v3.0.2
21 |
22 | # # Start the branch deployment
23 | # - uses: ./
24 | # id: branch-deploy
25 | # with:
26 | # admins: grantbirki
27 |
28 | # # Check out the ref from the output of the IssueOps command
29 | # - uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # pin@v3.0.2
30 | # if: ${{ steps.branch-deploy.outputs.continue == 'true' }}
31 | # with:
32 | # ref: ${{ steps.branch-deploy.outputs.ref }}
33 |
34 | # # Do some fake "noop" deployment logic here
35 | # - name: fake noop deploy
36 | # if: ${{ steps.branch-deploy.outputs.continue == 'true' && steps.branch-deploy.outputs.noop == 'true' }}
37 | # run: echo "I am doing a fake noop deploy"
38 |
39 | # # Do some fake "regular" deployment logic here
40 | # - name: fake regular deploy
41 | # if: ${{ steps.branch-deploy.outputs.continue == 'true' && steps.branch-deploy.outputs.noop != 'true' }}
42 | # run: echo "I am doing a fake regular deploy"
43 |
--------------------------------------------------------------------------------
/.github/workflows/package-check.yml:
--------------------------------------------------------------------------------
1 | name: package-check
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | workflow_dispatch:
9 |
10 | jobs:
11 | package-check:
12 | runs-on: ubuntu-latest
13 |
14 | steps:
15 | - uses: actions/checkout@v3
16 |
17 | - name: setup node
18 | uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # pin@v3.6.0
19 | with:
20 | node-version-file: .node-version
21 | cache: 'npm'
22 |
23 | - name: install dependencies
24 | run: npm ci
25 |
26 | - name: rebuild the dist/ directory
27 | run: npm run bundle
28 |
29 | - name: compare the expected and actual dist/ directories
30 | run: |
31 | if [ "$(git diff --ignore-space-at-eol dist/ | wc -l)" -gt "0" ]; then
32 | echo "Detected uncommitted changes after build. See status below:"
33 | git diff
34 | exit 1
35 | fi
36 | id: diff
37 |
38 | # If index.js was different than expected, upload the expected version as an artifact
39 | - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # pin@v3.1.2
40 | if: ${{ failure() && steps.diff.conclusion == 'failure' }}
41 | with:
42 | name: dist
43 | path: dist/
44 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: test
2 | on:
3 | pull_request:
4 | push:
5 | branches:
6 | - main
7 | - 'releases/*'
8 |
9 | permissions:
10 | contents: read
11 |
12 | jobs:
13 | test:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: actions/checkout@v3
17 |
18 | - name: setup node
19 | uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # pin@v3.6.0
20 | with:
21 | node-version-file: .node-version
22 | cache: 'npm'
23 |
24 | - name: install dependencies
25 | run: npm ci
26 |
27 | - name: test
28 | run: npm run ci-test
29 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # vscode
2 | .vscode
3 |
4 | # Dependency directory
5 | node_modules
6 |
7 | # Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore
8 | # Logs
9 | logs
10 | *.log
11 | npm-debug.log*
12 | yarn-debug.log*
13 | yarn-error.log*
14 | lerna-debug.log*
15 |
16 | # Diagnostic reports (https://nodejs.org/api/report.html)
17 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
18 |
19 | # Runtime data
20 | pids
21 | *.pid
22 | *.seed
23 | *.pid.lock
24 |
25 | # Directory for instrumented libs generated by jscoverage/JSCover
26 | lib-cov
27 |
28 | # Coverage directory used by tools like istanbul
29 | coverage
30 | *.lcov
31 |
32 | # nyc test coverage
33 | .nyc_output
34 |
35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
36 | .grunt
37 |
38 | # Bower dependency directory (https://bower.io/)
39 | bower_components
40 |
41 | # node-waf configuration
42 | .lock-wscript
43 |
44 | # Compiled binary addons (https://nodejs.org/api/addons.html)
45 | build/Release
46 |
47 | # Dependency directories
48 | jspm_packages/
49 |
50 | # TypeScript v1 declaration files
51 | typings/
52 |
53 | # Optional npm cache directory
54 | .npm
55 |
56 | # Optional eslint cache
57 | .eslintcache
58 |
59 | # Optional REPL history
60 | .node_repl_history
61 |
62 | # Output of 'npm pack'
63 | *.tgz
64 |
65 | # Yarn Integrity file
66 | .yarn-integrity
67 |
68 | # dotenv environment variables file
69 | .env
70 | .env.test
71 |
72 | # parcel-bundler cache (https://parceljs.org/)
73 | .cache
74 |
75 | # OS metadata
76 | .DS_Store
77 | Thumbs.db
78 |
79 | # Ignore built ts files
80 | __tests__/runner/*
81 | lib/**/*
82 |
83 | # Extra
84 | tmp
85 |
--------------------------------------------------------------------------------
/.node-version:
--------------------------------------------------------------------------------
1 | 18.9.0
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | dist/
2 | node_modules/
3 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 80,
3 | "tabWidth": 2,
4 | "useTabs": false,
5 | "semi": false,
6 | "singleQuote": true,
7 | "trailingComma": "none",
8 | "bracketSpacing": false,
9 | "arrowParens": "avoid"
10 | }
11 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing 💻
2 |
3 | All contributions are welcome and greatly appreciated!
4 |
5 | ## Steps to Contribute 💡
6 |
7 | > Check the `.node-version` file in the root of this repo so see what version of Node.js is required for local development - note, this can be different from the version of Node.js which runs the Action on GitHub runners. It is suggested to download [nodenv](https://github.com/nodenv/nodenv) which uses this file and manages your Node.js versions for you
8 |
9 | 1. Fork this repository
10 | 2. Commit your changes
11 | 3. Test your changes (learn how to test below)
12 | 4. Open a pull request back to this repository
13 | > Make sure to run `npm run all` as your final commit!
14 | 5. Notify the maintainers of this repository for peer review and approval
15 | 6. Merge!
16 |
17 | The maintainers of this repository will create a new release with your changes so that everyone can use the new release and enjoy the awesome features of branch deployments
18 |
19 | ## Testing 🧪
20 |
21 | This project requires **100%** test coverage
22 |
23 | > The branch-deploy Action is used by enterprises, governments, and open source organizations - it is critical that we have 100% test coverage to ensure that we are not introducing any regressions. All changes will be throughly tested by maintainers of this repository before a new release is created.
24 |
25 | ### Running the test suite (required)
26 |
27 | Simply run the following command to execute the entire test suite:
28 |
29 | ```bash
30 | npm run test
31 | ```
32 |
33 | > Note: this requires that you have already run `npm install`
34 |
35 | ### Testing directly with IssueOps
36 |
37 | > See the testing FAQs below for more information on this process
38 |
39 | You can test your changes by doing the following steps:
40 |
41 | 1. Commit your changes to the `main` branch on your fork
42 | 2. Open a new pull request
43 | 3. Run IssueOps commands on the pull request you just opened (`.deploy`, `.deploy noop`, `.deploy main`)
44 | 4. Ensure that all IssueOps commands work as expected on your testing PR
45 |
46 | ### Testing FAQs 🤔
47 |
48 | Answers to questions you might have around testing
49 |
50 | Q: Why do I have to commit my changes to `main`?
51 |
52 | A: The `on: issue_comment` workflow only uses workflow files from the `main` branch by design - [learn more](https://github.com/github/branch-deploy#security-)
53 |
54 | Q: How can I test my changes once my PR is merged and *before* a new release is created?
55 |
56 | A: You should create a repo like [this one](https://github.com/GrantBirki/actions-sandbox) that uses `github/branch-deploy@main` as the Action version and test your changes there
57 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | The MIT License (MIT)
3 |
4 | Copyright (c) 2018 GitHub, Inc. and contributors
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in
14 | all copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 | THE SOFTWARE.
--------------------------------------------------------------------------------
/__tests__/functions/actions-status.test.js:
--------------------------------------------------------------------------------
1 | import {actionStatus} from '../../src/functions/action-status'
2 |
3 | var context
4 | var octokit
5 | beforeEach(() => {
6 | jest.clearAllMocks()
7 | process.env.GITHUB_SERVER_URL = 'https://github.com'
8 | process.env.GITHUB_RUN_ID = '12345'
9 |
10 | context = {
11 | repo: {
12 | owner: 'corp',
13 | repo: 'test'
14 | },
15 | issue: {
16 | number: 1
17 | },
18 | payload: {
19 | comment: {
20 | id: '1'
21 | }
22 | }
23 | }
24 |
25 | octokit = {
26 | rest: {
27 | reactions: {
28 | createForIssueComment: jest.fn().mockReturnValueOnce({
29 | data: {}
30 | }),
31 | deleteForIssueComment: jest.fn().mockReturnValueOnce({
32 | data: {}
33 | })
34 | },
35 | issues: {
36 | createComment: jest.fn().mockReturnValueOnce({
37 | data: {}
38 | })
39 | }
40 | }
41 | }
42 | })
43 |
44 | test('adds a successful status message for a deployment', async () => {
45 | expect(
46 | await actionStatus(context, octokit, 123, 'Everything worked!', true)
47 | ).toBe(undefined)
48 | expect(octokit.rest.issues.createComment).toHaveBeenCalledWith({
49 | body: 'Everything worked!',
50 | issue_number: 1,
51 | owner: 'corp',
52 | repo: 'test'
53 | })
54 | expect(octokit.rest.reactions.createForIssueComment).toHaveBeenCalledWith({
55 | comment_id: '1',
56 | content: 'rocket',
57 | owner: 'corp',
58 | repo: 'test'
59 | })
60 | expect(octokit.rest.reactions.deleteForIssueComment).toHaveBeenCalledWith({
61 | comment_id: '1',
62 | owner: 'corp',
63 | reaction_id: 123,
64 | repo: 'test'
65 | })
66 | })
67 |
68 | test('adds a successful status message for a deployment (with alt message)', async () => {
69 | expect(
70 | await actionStatus(context, octokit, 123, 'Everything worked!', true, true)
71 | ).toBe(undefined)
72 | expect(octokit.rest.issues.createComment).toHaveBeenCalledWith({
73 | body: 'Everything worked!',
74 | issue_number: 1,
75 | owner: 'corp',
76 | repo: 'test'
77 | })
78 | expect(octokit.rest.reactions.createForIssueComment).toHaveBeenCalledWith({
79 | comment_id: '1',
80 | content: '+1',
81 | owner: 'corp',
82 | repo: 'test'
83 | })
84 | expect(octokit.rest.reactions.deleteForIssueComment).toHaveBeenCalledWith({
85 | comment_id: '1',
86 | owner: 'corp',
87 | reaction_id: 123,
88 | repo: 'test'
89 | })
90 | })
91 |
92 | test('adds a failure status message for a deployment', async () => {
93 | expect(
94 | await actionStatus(context, octokit, 123, 'Everything failed!', false)
95 | ).toBe(undefined)
96 | expect(octokit.rest.issues.createComment).toHaveBeenCalledWith({
97 | body: 'Everything failed!',
98 | issue_number: 1,
99 | owner: 'corp',
100 | repo: 'test'
101 | })
102 | expect(octokit.rest.reactions.createForIssueComment).toHaveBeenCalledWith({
103 | comment_id: '1',
104 | content: '-1',
105 | owner: 'corp',
106 | repo: 'test'
107 | })
108 | expect(octokit.rest.reactions.deleteForIssueComment).toHaveBeenCalledWith({
109 | comment_id: '1',
110 | owner: 'corp',
111 | reaction_id: 123,
112 | repo: 'test'
113 | })
114 | })
115 |
116 | test('uses default log url when the "message" variable is empty for failures', async () => {
117 | expect(await actionStatus(context, octokit, 123, '', false)).toBe(undefined)
118 | expect(octokit.rest.issues.createComment).toHaveBeenCalledWith({
119 | body: 'Unknown error, [check logs](https://github.com/corp/test/actions/runs/12345) for more details.',
120 | issue_number: 1,
121 | owner: 'corp',
122 | repo: 'test'
123 | })
124 | expect(octokit.rest.reactions.createForIssueComment).toHaveBeenCalledWith({
125 | comment_id: '1',
126 | content: '-1',
127 | owner: 'corp',
128 | repo: 'test'
129 | })
130 | expect(octokit.rest.reactions.deleteForIssueComment).toHaveBeenCalledWith({
131 | comment_id: '1',
132 | owner: 'corp',
133 | reaction_id: 123,
134 | repo: 'test'
135 | })
136 | })
137 |
138 | test('uses default log url when the "message" variable is empty for a success', async () => {
139 | expect(await actionStatus(context, octokit, 123, '', true)).toBe(undefined)
140 | expect(octokit.rest.issues.createComment).toHaveBeenCalledWith({
141 | body: 'Unknown error, [check logs](https://github.com/corp/test/actions/runs/12345) for more details.',
142 | issue_number: 1,
143 | owner: 'corp',
144 | repo: 'test'
145 | })
146 | expect(octokit.rest.reactions.createForIssueComment).toHaveBeenCalledWith({
147 | comment_id: '1',
148 | content: 'rocket',
149 | owner: 'corp',
150 | repo: 'test'
151 | })
152 | expect(octokit.rest.reactions.deleteForIssueComment).toHaveBeenCalledWith({
153 | comment_id: '1',
154 | owner: 'corp',
155 | reaction_id: 123,
156 | repo: 'test'
157 | })
158 | })
159 |
--------------------------------------------------------------------------------
/__tests__/functions/admin.test.js:
--------------------------------------------------------------------------------
1 | import {isAdmin} from '../../src/functions/admin'
2 | import * as github from '@actions/github'
3 | import * as core from '@actions/core'
4 |
5 | const debugMock = jest.spyOn(core, 'debug').mockImplementation(() => {})
6 | const warningMock = jest.spyOn(core, 'warning').mockImplementation(() => {})
7 |
8 | class NotFoundError extends Error {
9 | constructor(message) {
10 | super(message)
11 | this.status = 404
12 | }
13 | }
14 |
15 | class WildError extends Error {
16 | constructor(message) {
17 | super(message)
18 | this.status = 500
19 | }
20 | }
21 |
22 | var context
23 | var octokit
24 | beforeEach(() => {
25 | jest.clearAllMocks()
26 | process.env.INPUT_ADMINS_PAT = 'faketoken'
27 | process.env.INPUT_ADMINS =
28 | 'MoNaLiSa,@lisamona,octoawesome/octo-awEsome-team,bad$user'
29 |
30 | context = {
31 | actor: 'monalisa'
32 | }
33 |
34 | octokit = {
35 | request: jest.fn().mockReturnValueOnce({
36 | status: 204
37 | }),
38 | rest: {
39 | orgs: {
40 | get: jest.fn().mockReturnValueOnce({
41 | data: {id: '12345'}
42 | })
43 | },
44 | teams: {
45 | getByName: jest.fn().mockReturnValueOnce({
46 | data: {id: '567890'}
47 | })
48 | }
49 | }
50 | }
51 |
52 | jest.spyOn(github, 'getOctokit').mockImplementation(() => {
53 | return octokit
54 | })
55 | })
56 |
57 | test('runs isAdmin checks and finds a valid admin via handle reference', async () => {
58 | expect(await isAdmin(context)).toStrictEqual(true)
59 | expect(debugMock).toHaveBeenCalledWith(
60 | 'monalisa is an admin via handle reference'
61 | )
62 | })
63 |
64 | test('runs isAdmin checks and does not find a valid admin', async () => {
65 | process.env.INPUT_ADMINS = 'monalisa'
66 | const contextNoAdmin = {
67 | actor: 'eviluser'
68 | }
69 | expect(await isAdmin(contextNoAdmin)).toStrictEqual(false)
70 | expect(debugMock).toHaveBeenCalledWith('eviluser is not an admin')
71 | })
72 |
73 | test('runs isAdmin checks for an org team and fails due to no admins_pat', async () => {
74 | process.env.INPUT_ADMINS_PAT = 'false'
75 | process.env.INPUT_ADMINS = 'octoawesome/octo-awesome'
76 | expect(await isAdmin(context)).toStrictEqual(false)
77 | expect(warningMock).toHaveBeenCalledWith(
78 | 'No admins_pat provided, skipping admin check for org team membership'
79 | )
80 | })
81 |
82 | test('runs isAdmin checks for an org team and finds a valid user', async () => {
83 | process.env.INPUT_ADMINS = 'octoawesome/octo-awesome-team'
84 | expect(await isAdmin(context)).toStrictEqual(true)
85 | expect(debugMock).toHaveBeenCalledWith(
86 | 'monalisa is in octoawesome/octo-awesome-team'
87 | )
88 | expect(debugMock).toHaveBeenCalledWith(
89 | 'monalisa is an admin via org team reference'
90 | )
91 | })
92 |
93 | // This only handles the global failure case of any 404 in the admin.js file
94 | test('runs isAdmin checks for an org team and does not find the org', async () => {
95 | jest.spyOn(github, 'getOctokit').mockImplementation(() => {
96 | return {
97 | rest: {
98 | orgs: {
99 | get: jest
100 | .fn()
101 | .mockRejectedValueOnce(
102 | new NotFoundError('Reference does not exist')
103 | )
104 | }
105 | }
106 | }
107 | })
108 | process.env.INPUT_ADMINS = 'octoawesome/octo-awesome-team'
109 | expect(await isAdmin(context)).toStrictEqual(false)
110 | expect(debugMock).toHaveBeenCalledWith(
111 | 'monalisa is not a member of the octoawesome/octo-awesome-team team'
112 | )
113 | })
114 |
115 | // This only handles the global failure case of any 404 in the admin.js file
116 | test('runs isAdmin checks for an org team and does not find the team', async () => {
117 | jest.spyOn(github, 'getOctokit').mockImplementation(() => {
118 | return {
119 | rest: {
120 | orgs: {
121 | get: jest.fn().mockReturnValueOnce({
122 | data: {id: '12345'}
123 | })
124 | },
125 | teams: {
126 | getByName: jest
127 | .fn()
128 | .mockRejectedValueOnce(
129 | new NotFoundError('Reference does not exist')
130 | )
131 | }
132 | }
133 | }
134 | })
135 | process.env.INPUT_ADMINS = 'octoawesome/octo-awesome-team'
136 | expect(await isAdmin(context)).toStrictEqual(false)
137 | expect(debugMock).toHaveBeenCalledWith(
138 | 'monalisa is not a member of the octoawesome/octo-awesome-team team'
139 | )
140 | })
141 |
142 | // This test correctly tests if a user is a member of a team or not. If they are in a team a 204 is returned. If they are not a 404 is returned like in this test example
143 | test('runs isAdmin checks for an org team and does not find the user in the team', async () => {
144 | jest.spyOn(github, 'getOctokit').mockImplementation(() => {
145 | return {
146 | request: jest
147 | .fn()
148 | .mockRejectedValueOnce(new NotFoundError('Reference does not exist')),
149 | rest: {
150 | orgs: {
151 | get: jest.fn().mockReturnValueOnce({
152 | data: {id: '12345'}
153 | })
154 | },
155 | teams: {
156 | getByName: jest.fn().mockReturnValueOnce({
157 | data: {id: '567890'}
158 | })
159 | }
160 | }
161 | }
162 | })
163 | process.env.INPUT_ADMINS = 'octoawesome/octo-awesome-team'
164 | expect(await isAdmin(context)).toStrictEqual(false)
165 | expect(debugMock).toHaveBeenCalledWith(
166 | 'monalisa is not a member of the octoawesome/octo-awesome-team team'
167 | )
168 | })
169 |
170 | test('runs isAdmin checks for an org team and an unexpected status code is received from the request method with octokit', async () => {
171 | jest.spyOn(github, 'getOctokit').mockImplementation(() => {
172 | return {
173 | request: jest.fn().mockReturnValueOnce({
174 | status: 500
175 | }),
176 | rest: {
177 | orgs: {
178 | get: jest.fn().mockReturnValueOnce({
179 | data: {id: '12345'}
180 | })
181 | },
182 | teams: {
183 | getByName: jest.fn().mockReturnValueOnce({
184 | data: {id: '567890'}
185 | })
186 | }
187 | }
188 | }
189 | })
190 | process.env.INPUT_ADMINS = 'octoawesome/octo-awesome-team'
191 | expect(await isAdmin(context)).toStrictEqual(false)
192 | expect(debugMock).toHaveBeenCalledWith('monalisa is not an admin')
193 | expect(warningMock).toHaveBeenCalledWith(
194 | 'non 204 response from org team check: 500'
195 | )
196 | })
197 |
198 | test('runs isAdmin checks for an org team and an unexpected error is thrown from any API call', async () => {
199 | jest.spyOn(github, 'getOctokit').mockImplementation(() => {
200 | return {
201 | request: jest
202 | .fn()
203 | .mockRejectedValueOnce(new WildError('something went boom')),
204 | rest: {
205 | orgs: {
206 | get: jest.fn().mockReturnValueOnce({
207 | data: {id: '12345'}
208 | })
209 | },
210 | teams: {
211 | getByName: jest.fn().mockReturnValueOnce({
212 | data: {id: '567890'}
213 | })
214 | }
215 | }
216 | }
217 | })
218 | process.env.INPUT_ADMINS = 'octoawesome/octo-awesome-team'
219 | expect(await isAdmin(context)).toStrictEqual(false)
220 | expect(debugMock).toHaveBeenCalledWith('monalisa is not an admin')
221 | expect(warningMock).toHaveBeenCalledWith(
222 | 'Error checking org team membership: Error: something went boom'
223 | )
224 | })
225 |
--------------------------------------------------------------------------------
/__tests__/functions/context-check.test.js:
--------------------------------------------------------------------------------
1 | import {contextCheck} from '../../src/functions/context-check'
2 | import * as core from '@actions/core'
3 |
4 | const warningMock = jest.spyOn(core, 'warning')
5 | const saveStateMock = jest.spyOn(core, 'saveState')
6 |
7 | var context
8 | beforeEach(() => {
9 | jest.clearAllMocks()
10 | jest.spyOn(core, 'warning').mockImplementation(() => {})
11 | jest.spyOn(core, 'saveState').mockImplementation(() => {})
12 |
13 | context = {
14 | eventName: 'issue_comment',
15 | payload: {
16 | issue: {
17 | pull_request: {}
18 | }
19 | },
20 | pull_request: {
21 | number: 1
22 | }
23 | }
24 | })
25 |
26 | test('checks the event context and finds that it is valid', async () => {
27 | expect(await contextCheck(context)).toBe(true)
28 | })
29 |
30 | test('checks the event context and finds that it is invalid', async () => {
31 | context.eventName = 'push'
32 | expect(await contextCheck(context)).toBe(false)
33 | expect(warningMock).toHaveBeenCalledWith(
34 | 'This Action can only be run in the context of a pull request comment'
35 | )
36 | expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true')
37 | })
38 |
39 | test('checks the event context and throws an error', async () => {
40 | try {
41 | await contextCheck('evil')
42 | } catch (e) {
43 | expect(e.message).toBe(
44 | "Could not get PR event context: TypeError: Cannot read properties of undefined (reading 'issue')"
45 | )
46 | }
47 | })
48 |
--------------------------------------------------------------------------------
/__tests__/functions/deployment.test.js:
--------------------------------------------------------------------------------
1 | import {createDeploymentStatus} from '../../src/functions/deployment'
2 |
3 | var octokit
4 | var context
5 | beforeEach(() => {
6 | jest.clearAllMocks()
7 | process.env.GITHUB_SERVER_URL = 'https://github.com'
8 |
9 | context = {
10 | repo: {
11 | owner: 'corp',
12 | repo: 'test'
13 | },
14 | payload: {
15 | comment: {
16 | id: '1'
17 | }
18 | },
19 | runId: 12345
20 | }
21 |
22 | octokit = {
23 | rest: {
24 | repos: {
25 | createDeploymentStatus: jest.fn().mockReturnValueOnce({
26 | data: {}
27 | })
28 | }
29 | }
30 | }
31 | })
32 |
33 | const environment = 'production'
34 | const deploymentId = 123
35 | const ref = 'test-ref'
36 | const logUrl = 'https://github.com/corp/test/actions/runs/12345'
37 |
38 | test('creates an in_progress deployment status', async () => {
39 | expect(
40 | await createDeploymentStatus(
41 | octokit,
42 | context,
43 | ref,
44 | 'in_progress',
45 | deploymentId,
46 | environment
47 | )
48 | ).toStrictEqual({})
49 |
50 | expect(octokit.rest.repos.createDeploymentStatus).toHaveBeenCalledWith({
51 | owner: context.repo.owner,
52 | repo: context.repo.repo,
53 | ref: ref,
54 | deployment_id: deploymentId,
55 | state: 'in_progress',
56 | environment: environment,
57 | environment_url: null,
58 | log_url: logUrl
59 | })
60 | })
61 |
--------------------------------------------------------------------------------
/__tests__/functions/environment-targets.test.js:
--------------------------------------------------------------------------------
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 infoMock = jest.spyOn(core, 'info').mockImplementation(() => {})
7 | const debugMock = jest.spyOn(core, 'debug').mockImplementation(() => {})
8 | const warningMock = jest.spyOn(core, 'warning').mockImplementation(() => {})
9 | const saveStateMock = jest.spyOn(core, 'saveState').mockImplementation(() => {})
10 | const setOutputMock = jest.spyOn(core, 'setOutput').mockImplementation(() => {})
11 |
12 | beforeEach(() => {
13 | jest.clearAllMocks()
14 | jest.spyOn(actionStatus, 'actionStatus').mockImplementation(() => {
15 | return undefined
16 | })
17 | process.env.INPUT_ENVIRONMENT_TARGETS = 'production,development,staging'
18 | process.env.INPUT_GLOBAL_LOCK_FLAG = '--global'
19 | process.env.INPUT_LOCK_INFO_ALIAS = '.wcid'
20 | })
21 |
22 | const environment = 'production'
23 | const body = '.deploy'
24 | const trigger = '.deploy'
25 | const noop_trigger = 'noop'
26 | const stable_branch = 'main'
27 | const environmentUrls =
28 | 'production|https://example.com,development|https://dev.example.com,staging|http://staging.example.com'
29 |
30 | test('checks the comment body and does not find an explicit environment target', async () => {
31 | expect(
32 | await environmentTargets(
33 | environment,
34 | body,
35 | trigger,
36 | noop_trigger,
37 | stable_branch
38 | )
39 | ).toStrictEqual({environment: 'production', environmentUrl: null})
40 | expect(debugMock).toHaveBeenCalledWith(
41 | 'Using default environment for branch deployment'
42 | )
43 | })
44 |
45 | test('checks the comment body and finds an explicit environment target for development', async () => {
46 | expect(
47 | await environmentTargets(
48 | environment,
49 | '.deploy development',
50 | trigger,
51 | noop_trigger,
52 | stable_branch
53 | )
54 | ).toStrictEqual({environment: 'development', environmentUrl: null})
55 | expect(debugMock).toHaveBeenCalledWith(
56 | 'Found environment target for branch deploy: development'
57 | )
58 | })
59 |
60 | test('checks the comment body and finds an explicit environment target for staging on a noop deploy', async () => {
61 | expect(
62 | await environmentTargets(
63 | environment,
64 | '.deploy noop staging',
65 | trigger,
66 | noop_trigger,
67 | stable_branch
68 | )
69 | ).toStrictEqual({environment: 'staging', environmentUrl: null})
70 | expect(debugMock).toHaveBeenCalledWith(
71 | 'Found environment target for noop trigger: staging'
72 | )
73 | })
74 |
75 | test('checks the comment body and finds an explicit environment target for staging on a noop deploy with environment_urls set', async () => {
76 | expect(
77 | await environmentTargets(
78 | environment,
79 | '.deploy noop staging',
80 | trigger,
81 | noop_trigger,
82 | stable_branch,
83 | null,
84 | null,
85 | null,
86 | false, // lockChecks disabled
87 | environmentUrls
88 | )
89 | ).toStrictEqual({
90 | environment: 'staging',
91 | environmentUrl: 'http://staging.example.com'
92 | })
93 | expect(infoMock).toHaveBeenCalledWith(
94 | 'environment url detected: http://staging.example.com'
95 | )
96 | expect(debugMock).toHaveBeenCalledWith(
97 | 'Found environment target for noop trigger: staging'
98 | )
99 | expect(saveStateMock).toHaveBeenCalledWith(
100 | 'environment_url',
101 | 'http://staging.example.com'
102 | )
103 | expect(setOutputMock).toHaveBeenCalledWith(
104 | 'environment_url',
105 | 'http://staging.example.com'
106 | )
107 | })
108 |
109 | test('checks the comment body and uses the default production environment target with environment_urls set', async () => {
110 | expect(
111 | await environmentTargets(
112 | environment,
113 | '.deploy',
114 | trigger,
115 | noop_trigger,
116 | stable_branch,
117 | null,
118 | null,
119 | null,
120 | false, // lockChecks disabled
121 | environmentUrls
122 | )
123 | ).toStrictEqual({
124 | environment: 'production',
125 | environmentUrl: 'https://example.com'
126 | })
127 | expect(infoMock).toHaveBeenCalledWith(
128 | 'environment url detected: https://example.com'
129 | )
130 | expect(debugMock).toHaveBeenCalledWith(
131 | 'Using default environment for branch deployment'
132 | )
133 | expect(saveStateMock).toHaveBeenCalledWith(
134 | 'environment_url',
135 | 'https://example.com'
136 | )
137 | expect(setOutputMock).toHaveBeenCalledWith(
138 | 'environment_url',
139 | 'https://example.com'
140 | )
141 | })
142 |
143 | test('checks the comment body and finds an explicit environment target for a production deploy with environment_urls set but no valid url', async () => {
144 | expect(
145 | await environmentTargets(
146 | environment,
147 | '.deploy production',
148 | trigger,
149 | noop_trigger,
150 | stable_branch,
151 | null,
152 | null,
153 | null,
154 | false, // lockChecks disabled
155 | 'evil-production|example.com,development|dev.example.com,staging|'
156 | )
157 | ).toStrictEqual({environment: 'production', environmentUrl: null})
158 | expect(debugMock).toHaveBeenCalledWith(
159 | 'Found environment target for branch deploy: production'
160 | )
161 | expect(warningMock).toHaveBeenCalledWith(
162 | "no valid environment URL found for environment: production - setting environment URL to 'null' - please check your 'environment_urls' input"
163 | )
164 | expect(saveStateMock).toHaveBeenCalledWith('environment_url', 'null')
165 | expect(setOutputMock).toHaveBeenCalledWith('environment_url', 'null')
166 | })
167 |
168 | test('checks the comment body and finds an explicit environment target for a production deploy with environment_urls set but a url with a non-http(s) schema is provided', async () => {
169 | expect(
170 | await environmentTargets(
171 | environment,
172 | '.deploy production',
173 | trigger,
174 | noop_trigger,
175 | stable_branch,
176 | null,
177 | null,
178 | null,
179 | false, // lockChecks disabled
180 | 'production|example.com,development|dev.example.com,staging|'
181 | )
182 | ).toStrictEqual({environment: 'production', environmentUrl: null})
183 | expect(debugMock).toHaveBeenCalledWith(
184 | 'Found environment target for branch deploy: production'
185 | )
186 | expect(warningMock).toHaveBeenCalledWith(
187 | 'environment url does not match http(s) schema: example.com'
188 | )
189 | expect(warningMock).toHaveBeenCalledWith(
190 | "no valid environment URL found for environment: production - setting environment URL to 'null' - please check your 'environment_urls' input"
191 | )
192 | expect(saveStateMock).toHaveBeenCalledWith('environment_url', 'null')
193 | expect(setOutputMock).toHaveBeenCalledWith('environment_url', 'null')
194 | })
195 |
196 | test('checks the comment body and finds an explicit environment target for a production deploy with environment_urls set but the environment url for the given environment is disabled', async () => {
197 | expect(
198 | await environmentTargets(
199 | environment,
200 | '.deploy production',
201 | trigger,
202 | noop_trigger,
203 | stable_branch,
204 | null,
205 | null,
206 | null,
207 | false, // lockChecks disabled
208 | 'production|disabled,development|dev.example.com,staging|'
209 | )
210 | ).toStrictEqual({environment: 'production', environmentUrl: null})
211 | expect(debugMock).toHaveBeenCalledWith(
212 | 'Found environment target for branch deploy: production'
213 | )
214 | expect(infoMock).toHaveBeenCalledWith(
215 | 'environment url for production is explicitly disabled'
216 | )
217 | expect(saveStateMock).toHaveBeenCalledWith('environment_url', 'null')
218 | expect(setOutputMock).toHaveBeenCalledWith('environment_url', 'null')
219 | })
220 |
221 | test('checks the comment body and finds an explicit environment target for staging on a noop deploy with "to"', async () => {
222 | expect(
223 | await environmentTargets(
224 | environment,
225 | '.deploy noop to staging',
226 | trigger,
227 | noop_trigger,
228 | stable_branch
229 | )
230 | ).toStrictEqual({environment: 'staging', environmentUrl: null})
231 | expect(debugMock).toHaveBeenCalledWith(
232 | "Found environment target for noop trigger (with 'to'): staging"
233 | )
234 | })
235 |
236 | test('checks the comment body and finds an explicit environment target for production on a branch deploy with "to"', async () => {
237 | expect(
238 | await environmentTargets(
239 | environment,
240 | '.deploy to production',
241 | trigger,
242 | noop_trigger,
243 | stable_branch
244 | )
245 | ).toStrictEqual({environment: 'production', environmentUrl: null})
246 | expect(debugMock).toHaveBeenCalledWith(
247 | "Found environment target for branch deploy (with 'to'): production"
248 | )
249 | })
250 |
251 | test('checks the comment body on a noop deploy and does not find an explicit environment target', async () => {
252 | expect(
253 | await environmentTargets(
254 | environment,
255 | '.deploy noop',
256 | trigger,
257 | noop_trigger,
258 | stable_branch
259 | )
260 | ).toStrictEqual({environment: 'production', environmentUrl: null})
261 | expect(debugMock).toHaveBeenCalledWith(
262 | 'Using default environment for noop trigger'
263 | )
264 | })
265 |
266 | test('checks the comment body on a deployment and does not find any matching environment target (fails)', async () => {
267 | expect(
268 | await environmentTargets(
269 | environment,
270 | '.deploy to chaos',
271 | trigger,
272 | noop_trigger,
273 | stable_branch
274 | )
275 | ).toStrictEqual({environment: false, environmentUrl: null})
276 |
277 | const msg = dedent(`
278 | No matching environment target found. Please check your command and try again. You can read more about environment targets in the README of this Action.
279 |
280 | > The following environment targets are available: \`production,development,staging\`
281 | `)
282 |
283 | expect(warningMock).toHaveBeenCalledWith(msg)
284 | expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true')
285 | })
286 |
287 | test('checks the comment body on a stable branch deployment and finds a matching environment (with to)', async () => {
288 | expect(
289 | await environmentTargets(
290 | environment,
291 | '.deploy main to production',
292 | trigger,
293 | noop_trigger,
294 | stable_branch
295 | )
296 | ).toStrictEqual({environment: 'production', environmentUrl: null})
297 | expect(debugMock).toHaveBeenCalledWith(
298 | "Found environment target for stable branch deploy (with 'to'): production"
299 | )
300 | })
301 |
302 | test('checks the comment body on a stable branch deployment and finds a matching environment (without to)', async () => {
303 | expect(
304 | await environmentTargets(
305 | environment,
306 | '.deploy main production',
307 | trigger,
308 | noop_trigger,
309 | stable_branch
310 | )
311 | ).toStrictEqual({environment: 'production', environmentUrl: null})
312 | expect(debugMock).toHaveBeenCalledWith(
313 | 'Found environment target for stable branch deploy: production'
314 | )
315 | })
316 |
317 | test('checks the comment body on a stable branch deployment and uses the default environment', async () => {
318 | expect(
319 | await environmentTargets(
320 | environment,
321 | '.deploy main',
322 | trigger,
323 | noop_trigger,
324 | stable_branch
325 | )
326 | ).toStrictEqual({environment: 'production', environmentUrl: null})
327 | expect(debugMock).toHaveBeenCalledWith(
328 | 'Using default environment for stable branch deployment'
329 | )
330 | })
331 |
332 | test('checks the comment body on a stable branch deployment and does not find a matching environment', async () => {
333 | expect(
334 | await environmentTargets(
335 | environment,
336 | '.deploy main chaos',
337 | trigger,
338 | noop_trigger,
339 | stable_branch
340 | )
341 | ).toStrictEqual({environment: false, environmentUrl: null})
342 |
343 | const msg = dedent(`
344 | No matching environment target found. Please check your command and try again. You can read more about environment targets in the README of this Action.
345 |
346 | > The following environment targets are available: \`production,development,staging\`
347 | `)
348 |
349 | expect(warningMock).toHaveBeenCalledWith(msg)
350 | expect(saveStateMock).toHaveBeenCalledWith('bypass', 'true')
351 | })
352 |
353 | test('checks the comment body on a lock request and uses the default environment', async () => {
354 | expect(
355 | await environmentTargets(
356 | environment,
357 | '.lock', // comment body
358 | '.lock', // lock trigger
359 | '.unlock', // unlock trigger
360 | null, // stable_branch not used for lock/unlock requests
361 | null, // context
362 | null, // octokit
363 | null, // reaction_id
364 | true // enable lockChecks
365 | )
366 | ).toStrictEqual({environment: 'production', environmentUrl: null})
367 | expect(debugMock).toHaveBeenCalledWith(
368 | 'Using default environment for lock request'
369 | )
370 | })
371 |
372 | test('checks the comment body on a lock request with a reason and uses the default environment', async () => {
373 | expect(
374 | await environmentTargets(
375 | environment,
376 | '.lock --reason making a small change to our api because reasons', // comment body
377 | '.lock', // lock trigger
378 | '.unlock', // unlock trigger
379 | null, // stable_branch not used for lock/unlock requests
380 | null, // context
381 | null, // octokit
382 | null, // reaction_id
383 | true // enable lockChecks
384 | )
385 | ).toStrictEqual({environment: 'production', environmentUrl: null})
386 | expect(debugMock).toHaveBeenCalledWith(
387 | 'Using default environment for lock request'
388 | )
389 | })
390 |
391 | test('checks the comment body on a lock request with a reason and uses the explict environment with a bunch of horrible formatting', async () => {
392 | expect(
393 | await environmentTargets(
394 | environment,
395 | '.lock production --reason small change to mappings for risk rating - - 92*91-2408| ', // comment body
396 | '.lock', // lock trigger
397 | '.unlock', // unlock trigger
398 | null, // stable_branch not used for lock/unlock requests
399 | null, // context
400 | null, // octokit
401 | null, // reaction_id
402 | true // enable lockChecks
403 | )
404 | ).toStrictEqual({environment: 'production', environmentUrl: null})
405 | expect(debugMock).toHaveBeenCalledWith(
406 | 'Found environment target for lock request: production'
407 | )
408 | })
409 |
410 | test('checks the comment body on an unlock request and uses the default environment', async () => {
411 | expect(
412 | await environmentTargets(
413 | environment,
414 | '.unlock', // comment body
415 | '.lock', // lock trigger
416 | '.unlock', // unlock trigger
417 | null, // stable_branch not used for lock/unlock requests
418 | null, // context
419 | null, // octokit
420 | null, // reaction_id
421 | true // enable lockChecks
422 | )
423 | ).toStrictEqual({environment: 'production', environmentUrl: null})
424 | expect(debugMock).toHaveBeenCalledWith(
425 | 'Using default environment for unlock request'
426 | )
427 | })
428 |
429 | test('checks the comment body on an unlock request and uses the default environment (and uses --reason) even though it does not need to', async () => {
430 | expect(
431 | await environmentTargets(
432 | environment,
433 | '.unlock --reason oh wait this command does not need a reason.. oops', // comment body
434 | '.lock', // lock trigger
435 | '.unlock', // unlock trigger
436 | null, // stable_branch not used for lock/unlock requests
437 | null, // context
438 | null, // octokit
439 | null, // reaction_id
440 | true // enable lockChecks
441 | )
442 | ).toStrictEqual({environment: 'production', environmentUrl: null})
443 | expect(debugMock).toHaveBeenCalledWith(
444 | 'Using default environment for unlock request'
445 | )
446 | })
447 |
448 | test('checks the comment body on an unlock request and uses the development environment (and uses --reason) even though it does not need to', async () => {
449 | expect(
450 | await environmentTargets(
451 | environment,
452 | '.unlock development --reason oh wait this command does not need a reason.. oops', // comment body
453 | '.lock', // lock trigger
454 | '.unlock', // unlock trigger
455 | null, // stable_branch not used for lock/unlock requests
456 | null, // context
457 | null, // octokit
458 | null, // reaction_id
459 | true // enable lockChecks
460 | )
461 | ).toStrictEqual({environment: 'development', environmentUrl: null})
462 | expect(debugMock).toHaveBeenCalledWith(
463 | 'Found environment target for unlock request: development'
464 | )
465 | })
466 |
467 | test('checks the comment body on a lock info alias request and uses the default environment', async () => {
468 | expect(
469 | await environmentTargets(
470 | environment,
471 | '.wcid', // comment body
472 | '.lock', // lock trigger
473 | '.unlock', // unlock trigger
474 | null, // stable_branch not used for lock/unlock requests
475 | null, // context
476 | null, // octokit
477 | null, // reaction_id
478 | true // enable lockChecks
479 | )
480 | ).toStrictEqual({environment: 'production', environmentUrl: null})
481 | expect(debugMock).toHaveBeenCalledWith(
482 | 'Using default environment for lock info request'
483 | )
484 | })
485 |
486 | test('checks the comment body on a lock request and uses the production environment', async () => {
487 | expect(
488 | await environmentTargets(
489 | environment,
490 | '.lock production', // comment body
491 | '.lock', // lock trigger
492 | '.unlock', // unlock trigger
493 | null, // stable_branch not used for lock/unlock requests
494 | null, // context
495 | null, // octokit
496 | null, // reaction_id
497 | true // enable lockChecks
498 | )
499 | ).toStrictEqual({environment: 'production', environmentUrl: null})
500 | expect(debugMock).toHaveBeenCalledWith(
501 | 'Found environment target for lock request: production'
502 | )
503 | })
504 |
505 | test('checks the comment body on an unlock request and uses the development environment', async () => {
506 | expect(
507 | await environmentTargets(
508 | environment,
509 | '.unlock development', // comment body
510 | '.lock', // lock trigger
511 | '.unlock', // unlock trigger
512 | null, // stable_branch not used for lock/unlock requests
513 | null, // context
514 | null, // octokit
515 | null, // reaction_id
516 | true // enable lockChecks
517 | )
518 | ).toStrictEqual({environment: 'development', environmentUrl: null})
519 | expect(debugMock).toHaveBeenCalledWith(
520 | 'Found environment target for unlock request: development'
521 | )
522 | })
523 |
524 | test('checks the comment body on a lock info alias request and uses the development environment', async () => {
525 | expect(
526 | await environmentTargets(
527 | environment,
528 | '.wcid development', // comment body
529 | '.lock', // lock trigger
530 | '.unlock', // unlock trigger
531 | null, // stable_branch not used for lock/unlock requests
532 | null, // context
533 | null, // octokit
534 | null, // reaction_id
535 | true // enable lockChecks
536 | )
537 | ).toStrictEqual({environment: 'development', environmentUrl: null})
538 | expect(debugMock).toHaveBeenCalledWith(
539 | 'Found environment target for lock info request: development'
540 | )
541 | })
542 |
543 | test('checks the comment body on a lock info request and uses the development environment', async () => {
544 | expect(
545 | await environmentTargets(
546 | environment,
547 | '.lock --info development', // comment body
548 | '.lock', // lock trigger
549 | '.unlock', // unlock trigger
550 | null, // stable_branch not used for lock/unlock requests
551 | null, // context
552 | null, // octokit
553 | null, // reaction_id
554 | true // enable lockChecks
555 | )
556 | ).toStrictEqual({environment: 'development', environmentUrl: null})
557 | expect(debugMock).toHaveBeenCalledWith(
558 | 'Found environment target for lock request: development'
559 | )
560 | })
561 |
562 | test('checks the comment body on a lock info request and uses the development environment (using -d)', async () => {
563 | expect(
564 | await environmentTargets(
565 | environment,
566 | '.lock -d development', // comment body
567 | '.lock', // lock trigger
568 | '.unlock', // unlock trigger
569 | null, // stable_branch not used for lock/unlock requests
570 | null, // context
571 | null, // octokit
572 | null, // reaction_id
573 | true // enable lockChecks
574 | )
575 | ).toStrictEqual({environment: 'development', environmentUrl: null})
576 | expect(debugMock).toHaveBeenCalledWith(
577 | 'Found environment target for lock request: development'
578 | )
579 | })
580 |
--------------------------------------------------------------------------------
/__tests__/functions/help.test.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import {help} from '../../src/functions/help'
3 | import * as actionStatus from '../../src/functions/action-status'
4 |
5 | const debugMock = jest.spyOn(core, 'debug').mockImplementation(() => {})
6 |
7 | beforeEach(() => {
8 | jest.clearAllMocks()
9 | jest.spyOn(actionStatus, 'actionStatus').mockImplementation(() => {
10 | return undefined
11 | })
12 | jest.spyOn(core, 'debug').mockImplementation(() => {})
13 | })
14 |
15 | const context = {
16 | repo: {
17 | owner: 'corp',
18 | repo: 'test'
19 | },
20 | issue: {
21 | number: 1
22 | }
23 | }
24 | const octokit = {}
25 |
26 | const defaultInputs = {
27 | trigger: '.deploy',
28 | reaction: 'eyes',
29 | prefixOnly: 'true',
30 | environment: 'production',
31 | stable_branch: 'main',
32 | noop_trigger: 'noop',
33 | lock_trigger: '.lock',
34 | production_environment: 'production',
35 | environment_targets: 'production,staging,development',
36 | unlock_trigger: '.unlock',
37 | help_trigger: '.help',
38 | lock_info_alias: '.wcid',
39 | global_lock_flag: '--global',
40 | update_branch: 'warn',
41 | required_contexts: 'false',
42 | allowForks: 'true',
43 | skipCi: '',
44 | skipReviews: '',
45 | admins: 'false'
46 | }
47 |
48 | test('successfully calls help with defaults', async () => {
49 | expect(await help(octokit, context, 123, defaultInputs))
50 |
51 | expect(debugMock).toHaveBeenCalledWith(
52 | expect.stringMatching(/## 📚 Branch Deployment Help/)
53 | )
54 | })
55 |
56 | test('successfully calls help with non-defaults', async () => {
57 | const inputs = {
58 | trigger: '.deploy',
59 | reaction: 'eyes',
60 | prefixOnly: 'false',
61 | environment: 'production',
62 | stable_branch: 'main',
63 | noop_trigger: 'noop',
64 | lock_trigger: '.lock',
65 | production_environment: 'production',
66 | environment_targets: 'production,staging,development',
67 | unlock_trigger: '.unlock',
68 | help_trigger: '.help',
69 | lock_info_alias: '.wcid',
70 | global_lock_flag: '--global',
71 | update_branch: 'force',
72 | required_contexts: 'cat',
73 | allowForks: 'false',
74 | skipCi: 'development',
75 | skipReviews: 'development',
76 | admins: 'monalisa'
77 | }
78 |
79 | expect(await help(octokit, context, 123, inputs))
80 |
81 | expect(debugMock).toHaveBeenCalledWith(
82 | expect.stringMatching(/## 📚 Branch Deployment Help/)
83 | )
84 | })
85 |
86 | test('successfully calls help with non-defaults', async () => {
87 | const inputs = {
88 | trigger: '.deploy',
89 | reaction: 'eyes',
90 | prefixOnly: 'false',
91 | environment: 'production',
92 | stable_branch: 'main',
93 | noop_trigger: 'noop',
94 | lock_trigger: '.lock',
95 | production_environment: 'production',
96 | environment_targets: 'production,staging,development',
97 | unlock_trigger: '.unlock',
98 | help_trigger: '.help',
99 | lock_info_alias: '.wcid',
100 | global_lock_flag: '--global',
101 | update_branch: 'force',
102 | required_contexts: 'cat',
103 | allowForks: 'false',
104 | skipCi: 'development',
105 | skipReviews: 'development',
106 | admins: 'monalisa'
107 | }
108 |
109 | expect(await help(octokit, context, 123, inputs))
110 |
111 | expect(debugMock).toHaveBeenCalledWith(
112 | expect.stringMatching(/## 📚 Branch Deployment Help/)
113 | )
114 |
115 | var inputsSecond = inputs
116 | inputsSecond.update_branch = 'disabled'
117 | expect(await help(octokit, context, 123, inputsSecond))
118 |
119 | expect(debugMock).toHaveBeenCalledWith(
120 | expect.stringMatching(/## 📚 Branch Deployment Help/)
121 | )
122 | })
123 |
--------------------------------------------------------------------------------
/__tests__/functions/identical-commit-check.test.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import {identicalCommitCheck} from '../../src/functions/identical-commit-check'
3 |
4 | const setOutputMock = jest.spyOn(core, 'setOutput')
5 | const infoMock = jest.spyOn(core, 'info')
6 |
7 | var context
8 | var octokit
9 | beforeEach(() => {
10 | jest.clearAllMocks()
11 | jest.spyOn(core, 'setFailed').mockImplementation(() => {})
12 | jest.spyOn(core, 'setOutput').mockImplementation(() => {})
13 | jest.spyOn(core, 'info').mockImplementation(() => {})
14 | jest.spyOn(core, 'debug').mockImplementation(() => {})
15 |
16 | context = {
17 | repo: {
18 | owner: 'corp',
19 | repo: 'test'
20 | },
21 | payload: {
22 | comment: {
23 | id: '1'
24 | }
25 | }
26 | }
27 |
28 | octokit = {
29 | rest: {
30 | repos: {
31 | get: jest.fn().mockReturnValue({
32 | data: {
33 | default_branch: 'main'
34 | }
35 | }),
36 | getBranch: jest.fn().mockReturnValue({
37 | data: {
38 | commit: {
39 | sha: 'deadbeef'
40 | }
41 | }
42 | }),
43 | listCommits: jest.fn().mockReturnValue({
44 | data: [
45 | {
46 | sha: 'deadbeef',
47 | parents: [
48 | {
49 | sha: 'beefdead'
50 | }
51 | ]
52 | }
53 | ]
54 | }),
55 | listDeployments: jest.fn().mockReturnValue({
56 | data: [
57 | {
58 | sha: 'beefdead',
59 | id: 785395609,
60 | created_at: '2023-02-01T20:26:33Z',
61 | payload: {
62 | type: 'branch-deploy'
63 | }
64 | }
65 | ]
66 | }),
67 | compareCommitsWithBasehead: jest.fn().mockReturnValue({
68 | data: {
69 | status: 'identical'
70 | }
71 | })
72 | }
73 | }
74 | }
75 | })
76 |
77 | test('checks if the default branch sha and deployment sha are identical, and they are', async () => {
78 | expect(
79 | await identicalCommitCheck(octokit, context, 'production')
80 | ).toStrictEqual(true)
81 | expect(infoMock).toHaveBeenCalledWith(
82 | 'latest deployment sha is identical to the latest commit sha'
83 | )
84 | expect(setOutputMock).toHaveBeenCalledWith('continue', 'false')
85 | expect(setOutputMock).toHaveBeenCalledWith('environment', 'production')
86 | })
87 |
88 | test('checks if the default branch sha and deployment sha are identical, and they are not', async () => {
89 | octokit.rest.repos.compareCommitsWithBasehead = jest.fn().mockReturnValue({
90 | data: {
91 | status: 'not identical'
92 | }
93 | })
94 |
95 | expect(
96 | await identicalCommitCheck(octokit, context, 'production')
97 | ).toStrictEqual(false)
98 | expect(infoMock).toHaveBeenCalledWith(
99 | 'a new deployment will be created based on your configuration'
100 | )
101 | expect(setOutputMock).toHaveBeenCalledWith('continue', 'true')
102 | expect(setOutputMock).toHaveBeenCalledWith('environment', 'production')
103 | })
104 |
--------------------------------------------------------------------------------
/__tests__/functions/post-deploy.test.js:
--------------------------------------------------------------------------------
1 | import {postDeploy} from '../../src/functions/post-deploy'
2 | import * as actionStatus from '../../src/functions/action-status'
3 | import * as lock from '../../src/functions/lock'
4 | import * as unlock from '../../src/functions/unlock'
5 | import * as createDeploymentStatus from '../../src/functions/deployment'
6 | import * as core from '@actions/core'
7 |
8 | beforeEach(() => {
9 | jest.resetAllMocks()
10 | jest.spyOn(core, 'info').mockImplementation(() => {})
11 | jest.spyOn(actionStatus, 'actionStatus').mockImplementation(() => {
12 | return undefined
13 | })
14 | jest.spyOn(lock, 'lock').mockImplementation(() => {
15 | return {lockData: {sticky: true}}
16 | })
17 | jest
18 | .spyOn(createDeploymentStatus, 'createDeploymentStatus')
19 | .mockImplementation(() => {
20 | return undefined
21 | })
22 | jest.spyOn(core, 'debug').mockImplementation(() => {})
23 | })
24 |
25 | const context = {
26 | actor: 'monalisa',
27 | eventName: 'issue_comment',
28 | workflow: 'test-workflow',
29 | repo: {
30 | owner: 'corp',
31 | repo: 'test'
32 | },
33 | payload: {
34 | comment: {
35 | id: '1'
36 | }
37 | }
38 | }
39 |
40 | const octokit = {
41 | rest: {
42 | repos: {
43 | createDeploymentStatus: jest.fn().mockReturnValue({
44 | data: {}
45 | })
46 | }
47 | }
48 | }
49 |
50 | test('successfully completes a production branch deployment', async () => {
51 | const actionStatusSpy = jest.spyOn(actionStatus, 'actionStatus')
52 | const createDeploymentStatusSpy = jest.spyOn(
53 | createDeploymentStatus,
54 | 'createDeploymentStatus'
55 | )
56 | expect(
57 | await postDeploy(
58 | context,
59 | octokit,
60 | 123,
61 | 12345,
62 | 'success',
63 | 'Deployment has created 1 new server',
64 | 'test-ref',
65 | 'false',
66 | 456,
67 | 'production',
68 | '' // environment_url
69 | )
70 | ).toBe('success')
71 |
72 | expect(actionStatusSpy).toHaveBeenCalled()
73 | expect(actionStatusSpy).toHaveBeenCalledWith(
74 | {
75 | actor: 'monalisa',
76 | eventName: 'issue_comment',
77 | payload: {comment: {id: '1'}},
78 | repo: {owner: 'corp', repo: 'test'},
79 | workflow: 'test-workflow'
80 | },
81 | {
82 | rest: {
83 | repos: {
84 | createDeploymentStatus: octokit.rest.repos.createDeploymentStatus
85 | }
86 | }
87 | },
88 | 12345,
89 | ' ### Deployment Results ✅\n\n **monalisa** successfully deployed branch `test-ref` to **production**\n\n Show Results
\n\n Deployment has created 1 new server\n\n ',
90 | true
91 | )
92 | expect(createDeploymentStatusSpy).toHaveBeenCalled()
93 | expect(createDeploymentStatusSpy).toHaveBeenCalledWith(
94 | {
95 | rest: {
96 | repos: {
97 | createDeploymentStatus: octokit.rest.repos.createDeploymentStatus
98 | }
99 | }
100 | },
101 | {
102 | actor: 'monalisa',
103 | eventName: 'issue_comment',
104 | payload: {comment: {id: '1'}},
105 | repo: {owner: 'corp', repo: 'test'},
106 | workflow: 'test-workflow'
107 | },
108 | 'test-ref',
109 | 'success',
110 | 456,
111 | 'production',
112 | '' // environment_url
113 | )
114 | })
115 |
116 | test('successfully completes a production branch deployment with an environment url', async () => {
117 | const actionStatusSpy = jest.spyOn(actionStatus, 'actionStatus')
118 | const createDeploymentStatusSpy = jest.spyOn(
119 | createDeploymentStatus,
120 | 'createDeploymentStatus'
121 | )
122 | expect(
123 | await postDeploy(
124 | context,
125 | octokit,
126 | 123,
127 | 12345,
128 | 'success',
129 | 'Deployment has created 1 new server',
130 | 'test-ref',
131 | 'false',
132 | 456,
133 | 'production',
134 | 'https://example.com', // environment_url
135 | true // environment url in comment
136 | )
137 | ).toBe('success')
138 |
139 | expect(actionStatusSpy).toHaveBeenCalled()
140 | expect(actionStatusSpy).toHaveBeenCalledWith(
141 | {
142 | actor: 'monalisa',
143 | eventName: 'issue_comment',
144 | payload: {comment: {id: '1'}},
145 | repo: {owner: 'corp', repo: 'test'},
146 | workflow: 'test-workflow'
147 | },
148 | {
149 | rest: {
150 | repos: {
151 | createDeploymentStatus: octokit.rest.repos.createDeploymentStatus
152 | }
153 | }
154 | },
155 | 12345,
156 | ' ### Deployment Results ✅\n\n **monalisa** successfully deployed branch `test-ref` to **production**\n\n Show Results
\n\n Deployment has created 1 new server\n\n \n\n> **Environment URL:** [example.com](https://example.com)',
157 | true
158 | )
159 | expect(createDeploymentStatusSpy).toHaveBeenCalled()
160 | expect(createDeploymentStatusSpy).toHaveBeenCalledWith(
161 | {
162 | rest: {
163 | repos: {
164 | createDeploymentStatus: octokit.rest.repos.createDeploymentStatus
165 | }
166 | }
167 | },
168 | {
169 | actor: 'monalisa',
170 | eventName: 'issue_comment',
171 | payload: {comment: {id: '1'}},
172 | repo: {owner: 'corp', repo: 'test'},
173 | workflow: 'test-workflow'
174 | },
175 | 'test-ref',
176 | 'success',
177 | 456,
178 | 'production',
179 | 'https://example.com' // environment_url
180 | )
181 | })
182 |
183 | test('successfully completes a production branch deployment and removes a non-sticky lock', async () => {
184 | const lockSpy = jest.spyOn(lock, 'lock').mockImplementation(() => {
185 | return {lockData: {sticky: false}}
186 | })
187 | jest.spyOn(unlock, 'unlock').mockImplementation(() => {
188 | return true
189 | })
190 | const actionStatusSpy = jest.spyOn(actionStatus, 'actionStatus')
191 | const createDeploymentStatusSpy = jest.spyOn(
192 | createDeploymentStatus,
193 | 'createDeploymentStatus'
194 | )
195 | expect(
196 | await postDeploy(
197 | context,
198 | octokit,
199 | 123,
200 | 12345,
201 | 'success',
202 | 'Deployment has created 1 new server',
203 | 'test-ref',
204 | 'false',
205 | 456,
206 | 'production',
207 | '' // environment_url
208 | )
209 | ).toBe('success')
210 |
211 | expect(lockSpy).toHaveBeenCalled()
212 | expect(actionStatusSpy).toHaveBeenCalled()
213 | expect(actionStatusSpy).toHaveBeenCalledWith(
214 | {
215 | actor: 'monalisa',
216 | eventName: 'issue_comment',
217 | payload: {comment: {id: '1'}},
218 | repo: {owner: 'corp', repo: 'test'},
219 | workflow: 'test-workflow'
220 | },
221 | {
222 | rest: {
223 | repos: {
224 | createDeploymentStatus: octokit.rest.repos.createDeploymentStatus
225 | }
226 | }
227 | },
228 | 12345,
229 | ' ### Deployment Results ✅\n\n **monalisa** successfully deployed branch `test-ref` to **production**\n\n Show Results
\n\n Deployment has created 1 new server\n\n ',
230 | true
231 | )
232 | expect(createDeploymentStatusSpy).toHaveBeenCalled()
233 | expect(createDeploymentStatusSpy).toHaveBeenCalledWith(
234 | {
235 | rest: {
236 | repos: {
237 | createDeploymentStatus: octokit.rest.repos.createDeploymentStatus
238 | }
239 | }
240 | },
241 | {
242 | actor: 'monalisa',
243 | eventName: 'issue_comment',
244 | payload: {comment: {id: '1'}},
245 | repo: {owner: 'corp', repo: 'test'},
246 | workflow: 'test-workflow'
247 | },
248 | 'test-ref',
249 | 'success',
250 | 456,
251 | 'production',
252 | '' // environment_url
253 | )
254 | })
255 |
256 | test('successfully completes a noop branch deployment and removes a non-sticky lock', async () => {
257 | const lockSpy = jest.spyOn(lock, 'lock').mockImplementation(() => {
258 | return {lockData: {sticky: false}}
259 | })
260 | jest.spyOn(unlock, 'unlock').mockImplementation(() => {
261 | return true
262 | })
263 | const actionStatusSpy = jest.spyOn(actionStatus, 'actionStatus')
264 | expect(
265 | await postDeploy(
266 | context,
267 | octokit,
268 | 123,
269 | 12345,
270 | 'success',
271 | 'Deployment has created 1 new server',
272 | 'test-ref',
273 | 'true',
274 | 456,
275 | 'production'
276 | )
277 | ).toBe('success - noop')
278 |
279 | expect(lockSpy).toHaveBeenCalled()
280 | expect(actionStatusSpy).toHaveBeenCalled()
281 | expect(actionStatusSpy).toHaveBeenCalledWith(
282 | {
283 | actor: 'monalisa',
284 | eventName: 'issue_comment',
285 | payload: {comment: {id: '1'}},
286 | repo: {owner: 'corp', repo: 'test'},
287 | workflow: 'test-workflow'
288 | },
289 | {
290 | rest: {
291 | repos: {
292 | createDeploymentStatus: octokit.rest.repos.createDeploymentStatus
293 | }
294 | }
295 | },
296 | 12345,
297 | ' ### Deployment Results ✅\n\n **monalisa** successfully **noop** deployed branch `test-ref` to **production**\n\n Show Results
\n\n Deployment has created 1 new server\n\n ',
298 | true
299 | )
300 | })
301 |
302 | test('successfully completes a production branch deployment with no custom message', async () => {
303 | const actionStatusSpy = jest.spyOn(actionStatus, 'actionStatus')
304 | expect(
305 | await postDeploy(
306 | context,
307 | octokit,
308 | 123,
309 | 12345,
310 | 'success',
311 | '',
312 | 'test-ref',
313 | 'false',
314 | 456,
315 | 'production'
316 | )
317 | ).toBe('success')
318 | expect(actionStatusSpy).toHaveBeenCalled()
319 | expect(actionStatusSpy).toHaveBeenCalledWith(
320 | {
321 | actor: 'monalisa',
322 | eventName: 'issue_comment',
323 | payload: {comment: {id: '1'}},
324 | repo: {owner: 'corp', repo: 'test'},
325 | workflow: 'test-workflow'
326 | },
327 | {
328 | rest: {
329 | repos: {
330 | createDeploymentStatus: octokit.rest.repos.createDeploymentStatus
331 | }
332 | }
333 | },
334 | 12345,
335 | ' ### Deployment Results ✅\n\n **monalisa** successfully deployed branch `test-ref` to **production**',
336 | true
337 | )
338 | })
339 |
340 | test('successfully completes a noop branch deployment', async () => {
341 | expect(
342 | await postDeploy(
343 | context,
344 | octokit,
345 | 123,
346 | 12345,
347 | 'success',
348 | 'Deployment has created 1 new server',
349 | 'test-ref',
350 | 'true',
351 | 456,
352 | 'production'
353 | )
354 | ).toBe('success - noop')
355 | })
356 |
357 | test('updates with a failure for a production branch deployment', async () => {
358 | expect(
359 | await postDeploy(
360 | context,
361 | octokit,
362 | 123,
363 | 12345,
364 | 'failure',
365 | 'Deployment has failed to create 1 new server',
366 | 'test-ref',
367 | 'false',
368 | 456,
369 | 'production'
370 | )
371 | ).toBe('success')
372 | })
373 |
374 | test('updates with an unknown for a production branch deployment', async () => {
375 | expect(
376 | await postDeploy(
377 | context,
378 | octokit,
379 | 123,
380 | 12345,
381 | 'unknown',
382 | 'Deployment has failed to create 1 new server',
383 | 'test-ref',
384 | 'false',
385 | 456,
386 | 'production'
387 | )
388 | ).toBe('success')
389 | })
390 |
391 | test('fails due to no comment_id', async () => {
392 | try {
393 | await postDeploy(context, octokit, '')
394 | } catch (e) {
395 | expect(e.message).toBe('no comment_id provided')
396 | }
397 | })
398 |
399 | test('fails due to no status', async () => {
400 | try {
401 | await postDeploy(context, octokit, 123, '')
402 | } catch (e) {
403 | expect(e.message).toBe('no status provided')
404 | }
405 | })
406 |
407 | test('fails due to no ref', async () => {
408 | try {
409 | await postDeploy(
410 | context,
411 | octokit,
412 | 123,
413 | 'success',
414 | 'Deployment has created 1 new server',
415 | ''
416 | )
417 | } catch (e) {
418 | expect(e.message).toBe('no ref provided')
419 | }
420 | })
421 |
422 | test('fails due to no deployment_id', async () => {
423 | jest.resetAllMocks()
424 | try {
425 | await postDeploy(
426 | context,
427 | octokit,
428 | 123,
429 | 12345,
430 | 'success',
431 | 'Deployment has created 1 new server',
432 | 'test-ref',
433 | 'false',
434 | ''
435 | )
436 | } catch (e) {
437 | expect(e.message).toBe('no deployment_id provided')
438 | }
439 | })
440 |
441 | test('fails due to no environment', async () => {
442 | jest.resetAllMocks()
443 | try {
444 | await postDeploy(
445 | context,
446 | octokit,
447 | 123,
448 | 12345,
449 | 'success',
450 | 'Deployment has created 1 new server',
451 | 'test-ref',
452 | 'false',
453 | 456,
454 | ''
455 | )
456 | } catch (e) {
457 | expect(e.message).toBe('no environment provided')
458 | }
459 | })
460 |
461 | test('fails due to no noop', async () => {
462 | jest.resetAllMocks()
463 | try {
464 | await postDeploy(
465 | context,
466 | octokit,
467 | 123,
468 | 12345,
469 | 'success',
470 | 'Deployment has created 1 new server',
471 | 'test-ref',
472 | ''
473 | )
474 | } catch (e) {
475 | expect(e.message).toBe('no noop value provided')
476 | }
477 | })
478 |
--------------------------------------------------------------------------------
/__tests__/functions/post.test.js:
--------------------------------------------------------------------------------
1 | import {post} from '../../src/functions/post'
2 | import * as core from '@actions/core'
3 | import * as postDeploy from '../../src/functions/post-deploy'
4 | import * as contextCheck from '../../src/functions/context-check'
5 | import * as github from '@actions/github'
6 |
7 | const validInputs = {
8 | skip_completing: 'false'
9 | }
10 | const validStates = {
11 | ref: 'test-ref',
12 | comment_id: '123',
13 | noop: 'false',
14 | deployment_id: '456',
15 | environment: 'production',
16 | token: 'test-token',
17 | status: 'success'
18 | }
19 |
20 | const setFailedMock = jest.spyOn(core, 'setFailed').mockImplementation(() => {})
21 | const setWarningMock = jest.spyOn(core, 'warning').mockImplementation(() => {})
22 | const infoMock = jest.spyOn(core, 'info').mockImplementation(() => {})
23 |
24 | beforeEach(() => {
25 | jest.clearAllMocks()
26 | jest.spyOn(core, 'error').mockImplementation(() => {})
27 | jest.spyOn(core, 'debug').mockImplementation(() => {})
28 | jest.spyOn(core, 'getInput').mockImplementation(name => {
29 | return validInputs[name]
30 | })
31 | jest.spyOn(core, 'getState').mockImplementation(name => {
32 | return validStates[name]
33 | })
34 | jest.spyOn(postDeploy, 'postDeploy').mockImplementation(() => {
35 | return undefined
36 | })
37 | jest.spyOn(contextCheck, 'contextCheck').mockImplementation(() => {
38 | return true
39 | })
40 | jest.spyOn(github, 'getOctokit').mockImplementation(() => {
41 | return true
42 | })
43 | })
44 |
45 | test('successfully runs post() Action logic', async () => {
46 | expect(await post()).toBeUndefined()
47 | })
48 |
49 | test('exits due to an invalid Actions context', async () => {
50 | jest.spyOn(contextCheck, 'contextCheck').mockImplementation(() => {
51 | return false
52 | })
53 | expect(await post()).toBeUndefined()
54 | })
55 |
56 | test('exits due to a bypass being set', async () => {
57 | const bypassed = {
58 | bypass: 'true'
59 | }
60 | jest.spyOn(core, 'getState').mockImplementation(name => {
61 | return bypassed[name]
62 | })
63 | expect(await post()).toBeUndefined()
64 | expect(setWarningMock).toHaveBeenCalledWith('bypass set, exiting')
65 | })
66 |
67 | test('skips the process of completing a deployment', async () => {
68 | const skipped = {
69 | skip_completing: 'true'
70 | }
71 | jest.spyOn(core, 'getInput').mockImplementation(name => {
72 | return skipped[name]
73 | })
74 | expect(await post()).toBeUndefined()
75 | expect(infoMock).toHaveBeenCalledWith('skip_completing set, exiting')
76 | })
77 |
78 | test('throws an error', async () => {
79 | try {
80 | jest.spyOn(github, 'getOctokit').mockImplementation(() => {
81 | throw new Error('test error')
82 | })
83 | await post()
84 | } catch (e) {
85 | expect(e.message).toBe('test error')
86 | expect(setFailedMock).toHaveBeenCalledWith('test error')
87 | }
88 | })
89 |
--------------------------------------------------------------------------------
/__tests__/functions/react-emote.test.js:
--------------------------------------------------------------------------------
1 | import {reactEmote} from '../../src/functions/react-emote'
2 |
3 | const context = {
4 | repo: {
5 | owner: 'corp',
6 | repo: 'test'
7 | },
8 | payload: {
9 | comment: {
10 | id: '1'
11 | }
12 | }
13 | }
14 |
15 | const octokit = {
16 | rest: {
17 | reactions: {
18 | createForIssueComment: jest.fn().mockReturnValueOnce({
19 | data: {
20 | id: '1'
21 | }
22 | })
23 | }
24 | }
25 | }
26 |
27 | test('adds a reaction emote to a comment', async () => {
28 | expect(await reactEmote('eyes', context, octokit)).toStrictEqual({
29 | data: {id: '1'}
30 | })
31 | })
32 |
33 | test('returns if no reaction is specified', async () => {
34 | expect(await reactEmote('', context, octokit)).toBe(undefined)
35 | expect(await reactEmote(null, context, octokit)).toBe(undefined)
36 | })
37 |
38 | test('throws an error if a bad emote is used', async () => {
39 | try {
40 | await reactEmote('bad', context, octokit)
41 | } catch (e) {
42 | expect(e.message).toBe('Reaction "bad" is not a valid preset')
43 | }
44 | })
45 |
--------------------------------------------------------------------------------
/__tests__/functions/string-to-array.test.js:
--------------------------------------------------------------------------------
1 | import {stringToArray} from '../../src/functions/string-to-array'
2 | import * as core from '@actions/core'
3 |
4 | const debugMock = jest.spyOn(core, 'debug')
5 |
6 | beforeEach(() => {
7 | jest.clearAllMocks()
8 | jest.spyOn(core, 'debug').mockImplementation(() => {})
9 | })
10 |
11 | test('successfully converts a string to an array', async () => {
12 | expect(await stringToArray('production,staging,development')).toStrictEqual([
13 | 'production',
14 | 'staging',
15 | 'development'
16 | ])
17 | })
18 |
19 | test('successfully converts a single string item string to an array', async () => {
20 | expect(await stringToArray('production,')).toStrictEqual(['production'])
21 |
22 | expect(await stringToArray('production')).toStrictEqual(['production'])
23 | })
24 |
25 | test('successfully converts an empty string to an empty array', async () => {
26 | expect(await stringToArray('')).toStrictEqual([])
27 |
28 | expect(debugMock).toHaveBeenCalledWith(
29 | 'in stringToArray(), an empty String was found so an empty Array was returned'
30 | )
31 | })
32 |
33 | test('successfully converts garbage to an empty array', async () => {
34 | expect(await stringToArray(',,,')).toStrictEqual([])
35 | })
36 |
--------------------------------------------------------------------------------
/__tests__/functions/time-diff.test.js:
--------------------------------------------------------------------------------
1 | import {timeDiff} from '../../src/functions/time-diff'
2 |
3 | test('checks the time elapsed between two dates - days apart', async () => {
4 | expect(
5 | await timeDiff('2022-06-08T14:28:50.149Z', '2022-06-10T20:55:18.356Z')
6 | ).toStrictEqual('2d:6h:26m:28s')
7 | })
8 |
9 | test('checks the time elapsed between two dates - seconds apart', async () => {
10 | expect(
11 | await timeDiff('2022-06-10T20:55:20.999Z', '2022-06-10T20:55:50.356Z')
12 | ).toStrictEqual('0d:0h:0m:29s')
13 | })
14 |
--------------------------------------------------------------------------------
/__tests__/functions/trigger-check.test.js:
--------------------------------------------------------------------------------
1 | import {triggerCheck} from '../../src/functions/trigger-check'
2 | import * as core from '@actions/core'
3 |
4 | const setOutputMock = jest.spyOn(core, 'setOutput')
5 | const debugMock = jest.spyOn(core, 'debug')
6 |
7 | beforeEach(() => {
8 | jest.clearAllMocks()
9 | jest.spyOn(core, 'setOutput').mockImplementation(() => {})
10 | jest.spyOn(core, 'saveState').mockImplementation(() => {})
11 | jest.spyOn(core, 'debug').mockImplementation(() => {})
12 | })
13 |
14 | test('checks a message and finds a prefix trigger', async () => {
15 | const prefixOnly = true
16 | const body = '.deploy'
17 | const trigger = '.deploy'
18 | expect(await triggerCheck(prefixOnly, body, trigger)).toBe(true)
19 | expect(setOutputMock).toHaveBeenCalledWith('comment_body', '.deploy')
20 | })
21 |
22 | test('checks a message and does not find prefix trigger', async () => {
23 | const prefixOnly = true
24 | const body = '.bad'
25 | const trigger = '.deploy'
26 | expect(await triggerCheck(prefixOnly, body, trigger)).toBe(false)
27 | expect(setOutputMock).toHaveBeenCalledWith('comment_body', '.bad')
28 | expect(debugMock).toHaveBeenCalledWith(
29 | 'Trigger ".deploy" not found as comment prefix'
30 | )
31 | })
32 |
33 | test('checks a message and finds a global trigger', async () => {
34 | const prefixOnly = false
35 | const body = 'I want to .deploy'
36 | const trigger = '.deploy'
37 | expect(await triggerCheck(prefixOnly, body, trigger)).toBe(true)
38 | expect(setOutputMock).toHaveBeenCalledWith(
39 | 'comment_body',
40 | 'I want to .deploy'
41 | )
42 | })
43 |
44 | test('checks a message and does not find global trigger', async () => {
45 | const prefixOnly = false
46 | const body = 'I want to .ping a website'
47 | const trigger = '.deploy'
48 | expect(await triggerCheck(prefixOnly, body, trigger)).toBe(false)
49 | expect(setOutputMock).toHaveBeenCalledWith(
50 | 'comment_body',
51 | 'I want to .ping a website'
52 | )
53 | expect(debugMock).toHaveBeenCalledWith(
54 | 'Trigger ".deploy" not found in the comment body'
55 | )
56 | })
57 |
--------------------------------------------------------------------------------
/__tests__/functions/unlock.test.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import {unlock} from '../../src/functions/unlock'
3 | import * as actionStatus from '../../src/functions/action-status'
4 |
5 | class NotFoundError extends Error {
6 | constructor(message) {
7 | super(message)
8 | this.status = 422
9 | }
10 | }
11 |
12 | let octokit
13 | let context
14 |
15 | beforeEach(() => {
16 | jest.clearAllMocks()
17 | jest.spyOn(actionStatus, 'actionStatus').mockImplementation(() => {
18 | return undefined
19 | })
20 | jest.spyOn(core, 'info').mockImplementation(() => {})
21 | jest.spyOn(core, 'debug').mockImplementation(() => {})
22 | jest.spyOn(core, 'setOutput').mockImplementation(() => {})
23 | process.env.INPUT_ENVIRONMENT = 'production'
24 | process.env.INPUT_UNLOCK_TRIGGER = '.unlock'
25 | process.env.INPUT_GLOBAL_LOCK_FLAG = '--global'
26 |
27 | octokit = {
28 | rest: {
29 | git: {
30 | deleteRef: jest.fn().mockReturnValue({status: 204})
31 | }
32 | }
33 | }
34 |
35 | context = {
36 | repo: {
37 | owner: 'corp',
38 | repo: 'test'
39 | },
40 | issue: {
41 | number: 1
42 | },
43 | payload: {
44 | comment: {
45 | body: '.unlock'
46 | }
47 | }
48 | }
49 | })
50 |
51 | test('successfully releases a deployment lock with the unlock function', async () => {
52 | expect(await unlock(octokit, context, 123)).toBe(true)
53 | expect(octokit.rest.git.deleteRef).toHaveBeenCalledWith({
54 | owner: 'corp',
55 | repo: 'test',
56 | ref: 'heads/production-branch-deploy-lock'
57 | })
58 | })
59 |
60 | test('successfully releases a deployment lock with the unlock function and a passed in environment', async () => {
61 | expect(await unlock(octokit, context, 123, 'staging')).toBe(true)
62 | expect(octokit.rest.git.deleteRef).toHaveBeenCalledWith({
63 | owner: 'corp',
64 | repo: 'test',
65 | ref: 'heads/staging-branch-deploy-lock'
66 | })
67 | })
68 |
69 | test('successfully releases a GLOBAL deployment lock with the unlock function', async () => {
70 | context.payload.comment.body = '.unlock --global'
71 | expect(await unlock(octokit, context, 123)).toBe(true)
72 | expect(octokit.rest.git.deleteRef).toHaveBeenCalledWith({
73 | owner: 'corp',
74 | repo: 'test',
75 | ref: 'heads/global-branch-deploy-lock'
76 | })
77 | })
78 |
79 | test('successfully releases a development environment deployment lock with the unlock function', async () => {
80 | context.payload.comment.body = '.unlock development'
81 | expect(await unlock(octokit, context, 123)).toBe(true)
82 | expect(octokit.rest.git.deleteRef).toHaveBeenCalledWith({
83 | owner: 'corp',
84 | repo: 'test',
85 | ref: 'heads/development-branch-deploy-lock'
86 | })
87 | })
88 |
89 | test('successfully releases a development environment deployment lock with the unlock function even when a non-need --reason flag is passed in', async () => {
90 | context.payload.comment.body =
91 | '.unlock development --reason because i said so'
92 | expect(await unlock(octokit, context, 123)).toBe(true)
93 | expect(octokit.rest.git.deleteRef).toHaveBeenCalledWith({
94 | owner: 'corp',
95 | repo: 'test',
96 | ref: 'heads/development-branch-deploy-lock'
97 | })
98 | })
99 |
100 | test('successfully releases a deployment lock with the unlock function - silent mode', async () => {
101 | expect(await unlock(octokit, context, 123, null, true)).toBe(
102 | 'removed lock - silent'
103 | )
104 | expect(octokit.rest.git.deleteRef).toHaveBeenCalledWith({
105 | owner: 'corp',
106 | repo: 'test',
107 | ref: 'heads/production-branch-deploy-lock'
108 | })
109 | })
110 |
111 | test('fails to release a deployment lock due to a bad HTTP code from the GitHub API - silent mode', async () => {
112 | const badHttpOctokitMock = {
113 | rest: {
114 | git: {
115 | deleteRef: jest.fn().mockReturnValue({status: 500})
116 | }
117 | }
118 | }
119 | expect(await unlock(badHttpOctokitMock, context, 123, null, true)).toBe(
120 | 'failed to delete lock (bad status code) - silent'
121 | )
122 | })
123 |
124 | test('throws an error if an unhandled exception occurs - silent mode', async () => {
125 | const errorOctokitMock = {
126 | rest: {
127 | git: {
128 | deleteRef: jest.fn().mockRejectedValue(new Error('oh no'))
129 | }
130 | }
131 | }
132 | try {
133 | await unlock(errorOctokitMock, context, 123, null, true)
134 | } catch (e) {
135 | expect(e.message).toBe('Error: oh no')
136 | }
137 | })
138 |
139 | test('Does not find a deployment lock branch so it lets the user know - silent mode', async () => {
140 | const noBranchOctokitMock = {
141 | rest: {
142 | git: {
143 | deleteRef: jest
144 | .fn()
145 | .mockRejectedValue(new NotFoundError('Reference does not exist'))
146 | }
147 | }
148 | }
149 | expect(await unlock(noBranchOctokitMock, context, 123, null, true)).toBe(
150 | 'no deployment lock currently set - silent'
151 | )
152 | })
153 |
154 | test('fails to release a deployment lock due to a bad HTTP code from the GitHub API', async () => {
155 | const badHttpOctokitMock = {
156 | rest: {
157 | git: {
158 | deleteRef: jest.fn().mockReturnValue({status: 500})
159 | }
160 | }
161 | }
162 | expect(await unlock(badHttpOctokitMock, context, 123)).toBe(false)
163 | })
164 |
165 | test('Does not find a deployment lock branch so it lets the user know', async () => {
166 | const actionStatusSpy = jest
167 | .spyOn(actionStatus, 'actionStatus')
168 | .mockImplementation(() => {
169 | return undefined
170 | })
171 | const noBranchOctokitMock = {
172 | rest: {
173 | git: {
174 | deleteRef: jest
175 | .fn()
176 | .mockRejectedValue(new NotFoundError('Reference does not exist'))
177 | }
178 | }
179 | }
180 | expect(await unlock(noBranchOctokitMock, context, 123)).toBe(true)
181 | expect(actionStatusSpy).toHaveBeenCalledWith(
182 | context,
183 | noBranchOctokitMock,
184 | 123,
185 | '🔓 There is currently no `production` deployment lock set',
186 | true,
187 | true
188 | )
189 | })
190 |
191 | test('Does not find a deployment lock branch so it lets the user know', async () => {
192 | context.payload.comment.body = '.unlock --global'
193 | const actionStatusSpy = jest
194 | .spyOn(actionStatus, 'actionStatus')
195 | .mockImplementation(() => {
196 | return undefined
197 | })
198 | const noBranchOctokitMock = {
199 | rest: {
200 | git: {
201 | deleteRef: jest
202 | .fn()
203 | .mockRejectedValue(new NotFoundError('Reference does not exist'))
204 | }
205 | }
206 | }
207 | expect(await unlock(noBranchOctokitMock, context, 123)).toBe(true)
208 | expect(actionStatusSpy).toHaveBeenCalledWith(
209 | context,
210 | noBranchOctokitMock,
211 | 123,
212 | '🔓 There is currently no `global` deployment lock set',
213 | true,
214 | true
215 | )
216 | })
217 |
218 | test('throws an error if an unhandled exception occurs', async () => {
219 | const errorOctokitMock = {
220 | rest: {
221 | git: {
222 | deleteRef: jest.fn().mockRejectedValue(new Error('oh no'))
223 | }
224 | }
225 | }
226 | try {
227 | await unlock(errorOctokitMock, context, 123)
228 | } catch (e) {
229 | expect(e.message).toBe('Error: oh no')
230 | }
231 | })
232 |
--------------------------------------------------------------------------------
/__tests__/schemas/action.schema.yml:
--------------------------------------------------------------------------------
1 | # Action base info
2 | name:
3 | type: string
4 | required: true
5 | description:
6 | type: string
7 | required: true
8 | author:
9 | type: string
10 | required: true
11 | branding:
12 | icon:
13 | type: string
14 | required: true
15 | color:
16 | type: string
17 | required: true
18 |
19 | # runs section
20 | runs:
21 | using:
22 | type: string
23 | required: true
24 | main:
25 | type: string
26 | required: true
27 | post:
28 | type: string
29 | required: true
30 |
31 | # inputs section
32 | inputs:
33 | github_token:
34 | description:
35 | type: string
36 | required: true
37 | default:
38 | type: string
39 | required: true
40 | required:
41 | type: boolean
42 | required: true
43 | status:
44 | description:
45 | type: string
46 | required: true
47 | default:
48 | type: string
49 | required: true
50 | required:
51 | type: boolean
52 | required: true
53 | environment:
54 | description:
55 | type: string
56 | required: true
57 | required:
58 | type: boolean
59 | required: true
60 | default:
61 | type: string
62 | required: true
63 | environment_targets:
64 | description:
65 | type: string
66 | required: true
67 | required:
68 | type: boolean
69 | required: true
70 | default:
71 | type: string
72 | required: true
73 | environment_urls:
74 | description:
75 | type: string
76 | required: true
77 | required:
78 | type: boolean
79 | required: true
80 | default:
81 | type: string
82 | required: false
83 | environment_url_in_comment:
84 | description:
85 | type: string
86 | required: true
87 | required:
88 | type: boolean
89 | required: true
90 | default:
91 | type: string
92 | required: true
93 | production_environment:
94 | description:
95 | type: string
96 | required: true
97 | required:
98 | type: boolean
99 | required: true
100 | default:
101 | type: string
102 | required: true
103 | reaction:
104 | description:
105 | type: string
106 | required: true
107 | required:
108 | type: boolean
109 | required: true
110 | default:
111 | type: string
112 | required: true
113 | trigger:
114 | description:
115 | type: string
116 | required: true
117 | required:
118 | type: boolean
119 | required: true
120 | default:
121 | type: string
122 | required: true
123 | noop_trigger:
124 | description:
125 | type: string
126 | required: true
127 | required:
128 | type: boolean
129 | required: true
130 | default:
131 | type: string
132 | required: true
133 | lock_trigger:
134 | description:
135 | type: string
136 | required: true
137 | required:
138 | type: boolean
139 | required: true
140 | default:
141 | type: string
142 | required: true
143 | unlock_trigger:
144 | description:
145 | type: string
146 | required: true
147 | required:
148 | type: boolean
149 | required: true
150 | default:
151 | type: string
152 | required: true
153 | help_trigger:
154 | description:
155 | type: string
156 | required: true
157 | required:
158 | type: boolean
159 | required: true
160 | default:
161 | type: string
162 | required: true
163 | lock_info_alias:
164 | description:
165 | type: string
166 | required: true
167 | required:
168 | type: boolean
169 | required: true
170 | default:
171 | type: string
172 | required: true
173 | global_lock_flag:
174 | description:
175 | type: string
176 | required: true
177 | required:
178 | type: boolean
179 | required: true
180 | default:
181 | type: string
182 | required: true
183 | stable_branch:
184 | description:
185 | type: string
186 | required: true
187 | required:
188 | type: boolean
189 | required: true
190 | default:
191 | type: string
192 | required: true
193 | prefix_only:
194 | description:
195 | type: string
196 | required: true
197 | required:
198 | type: boolean
199 | required: true
200 | default:
201 | type: string
202 | required: true
203 | update_branch:
204 | description:
205 | type: string
206 | required: true
207 | required:
208 | type: boolean
209 | required: true
210 | default:
211 | type: string
212 | required: true
213 | required_contexts:
214 | description:
215 | type: string
216 | required: true
217 | required:
218 | type: boolean
219 | required: true
220 | default:
221 | type: string
222 | required: true
223 | skip_ci:
224 | description:
225 | type: string
226 | required: true
227 | required:
228 | type: boolean
229 | required: true
230 | default:
231 | type: string
232 | required: false
233 | skip_reviews:
234 | description:
235 | type: string
236 | required: true
237 | required:
238 | type: boolean
239 | required: true
240 | default:
241 | type: string
242 | required: false
243 | allow_forks:
244 | description:
245 | type: string
246 | required: true
247 | required:
248 | type: boolean
249 | required: true
250 | default:
251 | type: string
252 | required: true
253 | admins:
254 | description:
255 | type: string
256 | required: true
257 | required:
258 | type: boolean
259 | required: true
260 | default:
261 | type: string
262 | required: true
263 | admins_pat:
264 | description:
265 | type: string
266 | required: true
267 | required:
268 | type: boolean
269 | required: true
270 | default:
271 | type: string
272 | required: true
273 | merge_deploy_mode:
274 | description:
275 | type: string
276 | required: true
277 | required:
278 | type: boolean
279 | required: true
280 | default:
281 | type: string
282 | required: true
283 | skip_completing:
284 | description:
285 | type: string
286 | required: true
287 | required:
288 | type: boolean
289 | required: true
290 | default:
291 | type: string
292 | required: true
293 |
294 | # outputs section
295 | outputs:
296 | triggered:
297 | description:
298 | type: string
299 | required: true
300 | comment_body:
301 | description:
302 | type: string
303 | required: true
304 | environment:
305 | description:
306 | type: string
307 | required: true
308 | noop:
309 | description:
310 | type: string
311 | required: true
312 | sha:
313 | description:
314 | type: string
315 | required: true
316 | ref:
317 | description:
318 | type: string
319 | required: true
320 | comment_id:
321 | description:
322 | type: string
323 | required: true
324 | type:
325 | description:
326 | type: string
327 | required: true
328 | continue:
329 | description:
330 | type: string
331 | required: true
332 | fork:
333 | description:
334 | type: string
335 | required: true
336 | fork_ref:
337 | description:
338 | type: string
339 | required: true
340 | fork_label:
341 | description:
342 | type: string
343 | required: true
344 | fork_checkout:
345 | description:
346 | type: string
347 | required: true
348 | fork_full_name:
349 | description:
350 | type: string
351 | required: true
352 | deployment_id:
353 | description:
354 | type: string
355 | required: true
356 | environment_url:
357 | description:
358 | type: string
359 | required: true
360 | initial_reaction_id:
361 | description:
362 | type: string
363 | required: true
364 | actor_handle:
365 | description:
366 | type: string
367 | required: true
368 | global_lock_claimed:
369 | description:
370 | type: string
371 | required: true
372 | global_lock_released:
373 | description:
374 | type: string
375 | required: true
376 |
--------------------------------------------------------------------------------
/action.yml:
--------------------------------------------------------------------------------
1 | name: "branch-deploy"
2 | description: "Enabling Branch Deployments through IssueOps with GitHub Actions"
3 | author: "Grant Birkinbine"
4 | branding:
5 | icon: 'git-branch'
6 | color: 'gray-dark'
7 | inputs:
8 | github_token:
9 | description: The GitHub token used to create an authenticated client - Provided for you by default!
10 | default: ${{ github.token }}
11 | required: true
12 | status:
13 | description: The status of the GitHub Actions - For use in the post run workflow - Provided for you by default!
14 | default: ${{ job.status }}
15 | required: true
16 | environment:
17 | description: 'The name of the default environment to deploy to. Example: by default, if you type `.deploy`, it will assume "production" as the default environment'
18 | required: false
19 | default: "production"
20 | environment_targets:
21 | description: 'Optional (or additional) target environments to select for use with deployments. Example, "production,development,staging". Example usage: `.deploy to development`, `.deploy to production`, `.deploy to staging`'
22 | required: false
23 | default: "production,development,staging"
24 | environment_urls:
25 | description: 'Optional target environment URLs to use with deployments. This input option is a mapping of environment names to URLs and the environment names must match the "environment_targets" input option. This option is a comma separated list with pipes (|) separating the environment from the URL. Note: "disabled" is a special keyword to disable an environment url if you enable this option. Format: "|,|,etc" Example: "production|https://myapp.com,development|https://dev.myapp.com,staging|disabled"'
26 | required: false
27 | default: ""
28 | environment_url_in_comment:
29 | description: 'If the environment_url detected in the deployment should be appended to the successful deployment comment or not. Examples: "true" or "false"'
30 | required: false
31 | default: "true"
32 | production_environment:
33 | description: 'The name of the production environment. Example: "production". By default, GitHub will set the "production_environment" to "true" if the environment name is "production". This option allows you to override that behavior so you can use "prod", "prd", "main", etc. as your production environment name.'
34 | required: false
35 | default: "production"
36 | reaction:
37 | description: 'If set, the specified emoji "reaction" is put on the comment to indicate that the trigger was detected. For example, "rocket" or "eyes"'
38 | required: false
39 | default: "eyes"
40 | trigger:
41 | description: 'The string to look for in comments as an IssueOps trigger. Example: ".deploy"'
42 | required: false
43 | default: ".deploy"
44 | noop_trigger:
45 | description: 'The string to look for in comments as an IssueOps noop trigger. Example: "noop"'
46 | required: false
47 | default: "noop"
48 | lock_trigger:
49 | description: 'The string to look for in comments as an IssueOps lock trigger. Used for locking branch deployments on a specific branch. Example: ".lock"'
50 | required: false
51 | default: ".lock"
52 | unlock_trigger:
53 | description: 'The string to look for in comments as an IssueOps unlock trigger. Used for unlocking branch deployments. Example: ".unlock"'
54 | required: false
55 | default: ".unlock"
56 | help_trigger:
57 | description: 'The string to look for in comments as an IssueOps help trigger. Example: ".help"'
58 | required: false
59 | default: ".help"
60 | lock_info_alias:
61 | description: 'An alias or shortcut to get details about the current lock (if it exists) Example: ".info"'
62 | required: false
63 | default: ".wcid"
64 | global_lock_flag:
65 | description: 'The flag to pass into the lock command to lock all environments. Example: "--global"'
66 | required: false
67 | default: "--global"
68 | stable_branch:
69 | description: 'The name of a stable branch to deploy to (rollbacks). Example: "main"'
70 | required: false
71 | default: "main"
72 | prefix_only:
73 | description: 'If "false", the trigger can match anywhere in the comment'
74 | required: false
75 | default: "true"
76 | update_branch:
77 | description: 'Determine how you want this Action to handle "out-of-date" branches. Available options: "disabled", "warn", "force". "disabled" means that the Action will not care if a branch is out-of-date. "warn" means that the Action will warn the user that a branch is out-of-date and exit without deploying. "force" means that the Action will force update the branch. Note: The "force" option is not recommended due to Actions not being able to re-run CI on commits originating from Actions itself'
78 | required: false
79 | default: "warn"
80 | required_contexts:
81 | description: 'Manually enforce commit status checks before a deployment can continue. Only use this option if you wish to manually override the settings you have configured for your branch protection settings for your GitHub repository. Default is "false" - Example value: "context1,context2,context3" - In most cases you will not need to touch this option'
82 | required: false
83 | default: "false"
84 | skip_ci:
85 | description: 'A comma separated list of environments that will not use passing CI as a requirement for deployment. Use this option to explicitly bypass branch protection settings for a certain environment in your repository. Default is an empty string "" - Example: "development,staging"'
86 | required: false
87 | default: ""
88 | skip_reviews:
89 | description: 'A comma separated list of environment that will not use reviews/approvals as a requirement for deployment. Use this options to explicitly bypass branch protection settings for a certain environment in your repository. Default is an empty string "" - Example: "development,staging"'
90 | required: false
91 | default: ""
92 | allow_forks:
93 | description: 'Allow branch deployments to run on repository forks. If you want to harden your workflows, this option can be set to false. Default is "true"'
94 | required: false
95 | default: "true"
96 | admins:
97 | description: 'A comma separated list of GitHub usernames or teams that should be considered admins by this Action. Admins can deploy pull requests without the need for branch protection approvals. Example: "monalisa,octocat,my-org/my-team"'
98 | required: false
99 | default: "false"
100 | admins_pat:
101 | description: 'A GitHub personal access token with "read:org" scopes. This is only needed if you are using the "admins" option with a GitHub org team. For example: "my-org/my-team"'
102 | required: false
103 | default: "false"
104 | merge_deploy_mode:
105 | description: This is an advanced option that is an alternate workflow bundled into this Action. You can control how merge commits are handled when a PR is merged into your repository's default branch. If the merge commit SHA matches the latest deployment for the same environment, then the 'continue' output will be set to 'false' which indicates that a deployment should not be performed again as the latest deployment is identical. If the merge commit SHA does not match the latest deployment for the same environment, then the 'continue' output will be set to 'true' which indicates that a deployment should be performed. With this option, the 'environment' output is also set for subsequent steps to reference
106 | required: false
107 | default: "false"
108 | skip_completing:
109 | description: 'If set to "true", skip the process of completing a deployment. You must manually create a deployment status after the deployment is complete. Default is "false"'
110 | required: false
111 | default: "false"
112 | outputs:
113 | triggered:
114 | description: 'The string "true" if the trigger was found, otherwise the string "false"'
115 | comment_body:
116 | description: The comment body
117 | environment:
118 | description: The environment that has been selected for a deployment
119 | noop:
120 | description: 'The string "true" if the noop trigger was found, otherwise the string "false" - Use this to conditionally control whether your deployment runs as a noop or not'
121 | sha:
122 | description: The sha of the branch to be deployed
123 | ref:
124 | description: The ref (branch or sha) to use with deployment
125 | comment_id:
126 | description: The comment id which triggered this deployment
127 | type:
128 | description: "The type of trigger that was detected (examples: deploy, lock, unlock)"
129 | continue:
130 | description: 'The string "true" if the deployment should continue, otherwise empty - Use this to conditionally control if your deployment should proceed or not'
131 | fork:
132 | description: 'The string "true" if the pull request is a fork, otherwise "false"'
133 | fork_ref:
134 | description: 'The true ref of the fork'
135 | fork_label:
136 | description: 'The API label field returned for the fork'
137 | fork_checkout:
138 | description: 'The console command presented in the GitHub UI to checkout a given fork locally'
139 | fork_full_name:
140 | description: 'The full name of the fork in "org/repo" format'
141 | deployment_id:
142 | description: The ID of the deployment created by running this action
143 | environment_url:
144 | description: The environment URL detected and used for the deployment (sourced from the environment_urls input)
145 | initial_reaction_id:
146 | description: The reaction id for the initial reaction on the trigger comment
147 | actor_handle:
148 | description: The handle of the user who triggered the action
149 | global_lock_claimed:
150 | description: 'The string "true" if the global lock was claimed'
151 | global_lock_released:
152 | description: 'The string "true" if the global lock was released'
153 | runs:
154 | using: "node16"
155 | main: "dist/index.js"
156 | post: "dist/index.js"
157 |
--------------------------------------------------------------------------------
/badges/coverage.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/assets/ship-it.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marcusmccarty/branch-deploy/b20ca58e7868caccf612dda87203738448046f4b/docs/assets/ship-it.jpg
--------------------------------------------------------------------------------
/docs/custom-deployment-messages.md:
--------------------------------------------------------------------------------
1 | # Custom Deployment Messages ✏️
2 |
3 | > This is useful to display to the user the status of your deployment. For example, you could display the results of a `terraform apply` in the deployment comment
4 |
5 | You can use the GitHub Actions environment to export custom deployment messages from your workflow to be referenced in the post run workflow for the `branch-deploy` Action that comments results back to your PR
6 |
7 | Simply set the environment variable `DEPLOY_MESSAGE` to the message you want to be displayed in the post run workflow
8 |
9 | Bash Example:
10 |
11 | ```bash
12 | echo "DEPLOY_MESSAGE=" >> $GITHUB_ENV
13 | ```
14 |
15 | Actions Workflow Example:
16 |
17 | ```yaml
18 | # Do some fake "noop" deployment logic here
19 | - name: fake noop deploy
20 | if: ${{ steps.branch-deploy.outputs.continue == 'true' && steps.branch-deploy.outputs.noop == 'true' }}
21 | run: |
22 | echo "DEPLOY_MESSAGE=I would have **updated** 1 server" >> $GITHUB_ENV
23 | echo "I am doing a fake noop deploy"
24 | ```
25 |
26 | ## Additional Custom Message Examples 📚
27 |
28 | ### Adding newlines to your message
29 |
30 | ```bash
31 | echo "DEPLOY_MESSAGE=NOOP Result:\nI would have **updated** 1 server" >> $GITHUB_ENV
32 | ```
33 |
34 | ### Multi-line strings ([reference](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#example-2))
35 |
36 | ```bash
37 | echo 'DEPLOY_MESSAGE<> $GITHUB_ENV
38 | echo "$SOME_MULTI_LINE_STRING_HERE" >> $GITHUB_ENV
39 | echo 'EOF' >> $GITHUB_ENV
40 | ```
41 |
42 | > Where `$SOME_MULTI_LINE_STRING_HERE` is a bash variable containing a multi-line string
43 |
44 | ### Adding a code block to your message
45 |
46 | ```bash
47 | echo "DEPLOY_MESSAGE=\`\`\`yaml\nname: value\n\`\`\`" >> $GITHUB_ENV
48 | ```
49 |
50 | ## How does this work? 🤔
51 |
52 | To add custom messages to our final deployment message we need to use the GitHub Actions environment. This is so that we can dynamically pass data into the post action workflow that leaves a comment on our PR. The post action workflow will look to see if this environment variable is set (`DEPLOY_MESSAGE`). If the variable is set, it adds to to the PR comment. Otherwise, it will use a simple comment body that doesn't include the custom message.
53 |
--------------------------------------------------------------------------------
/docs/locks.md:
--------------------------------------------------------------------------------
1 | # Deployment Locks and Actions Concurrency 🔓
2 |
3 | > Only run one deployment at a time
4 |
5 | There are multiple ways to leverage this action for deployment locks! Let's take a look at each option
6 |
7 | ## Deployment Locks
8 |
9 | The suggested way to go about deployment locking is to use the built in locking feature in this Action!
10 |
11 | Just like how you can comment `.deploy` on a pull request to trigger a deployment, you can also comment `.lock` to lock deployments. This will prevent other users from triggering a deployment. The lock is associated with your GitHub handle, so you will be able to deploy any pull request in the repository and as many times as you want. Any other user who attempts a deployment while your lock is active will get a comment on their PR telling them that a lock is in effect.
12 |
13 | To release the deployment lock, simply comment `.unlock` on any pull request in the repository at anytime. Please be aware that other users can run this same command to remove the lock (in case you get offline and forget to do so 😉)
14 |
15 | These deployment locks come in two flavors:
16 |
17 | - `sticky`
18 | - `non-sticky`
19 |
20 | **sticky** locks are locks that persist until you remove them. As seen in the example above, the `.lock` command creates a **sticky** lock that will persist until someone runs `.unlock`
21 |
22 | **non-sticky** locks are temporary locks that only exist during a deployment. This action will automatically create a **non-sticky** lock for you when you run `.deploy`. It does this to prevent another user from running `.deploy` in another pull request and creating a deployment conflict
23 |
24 | Deployment locks in relation to environments also come in two flavors:
25 |
26 | - environment specific
27 | - global
28 |
29 | **environment specific** locks are locks that are associated with a specific environment. This means that if you have two environments, `staging` and `production`, you can have a lock on `staging` and another lock on `production` at the same time. These locks are indepdent of each other and will not prevent you from deploying to the other environment if another user has a lock in effect.
30 |
31 | **global** locks are locks that are associated with the entire project/repository. This means that if you have two environments, `staging` and `production`, you can have a lock on the entire repository and prevent any deployments to either environment.
32 |
33 | ### Deployment Lock Core Concepts
34 |
35 | Let's review the core concepts of deployment locks in a short summary:
36 |
37 | - Deployment locks are used to prevent multiple deployments from running at the same time and breaking things
38 | - Non-sticky locks are created automatically when running `.deploy` or `.deploy noop`
39 | - Sticky locks are created manually by commenting `.lock` on a pull request - They will persist until you remove them with `.unlock`
40 | - Locks are associated to a user's GitHub handle - This user can deploy any pull request in the repository and as many times as they want
41 | - Any user can remove a lock by commenting `.unlock` on any pull request in the repository
42 | - Details about a lock can be viewed with `.lock --details`
43 | - Locks can either be environment specific or global
44 | - Like all the features of this Action, users need `write` permissions or higher to use a command
45 |
46 | ### How do Deployment Locks Work?
47 |
48 | This Action uses GitHub branches to create a deployment lock. When you run `.lock` the following happens:
49 |
50 | 1. The Action checks to see if a global lock already exists, if it doesn't it will then check to see if an environment specific lock exists
51 | 2. If a lock does not exists it begins to create one for you
52 | 3. The Action creates a new branch called `-branch-deploy-lock`
53 | 4. The Action then creates a lock file called `lock.json` on the new branch
54 | 5. The `lock.json` file contains metadata about the lock
55 |
56 | Now when new deployments are run, they will check if a lock exists. If it does and it doesn't belong to you, your deployment is rejected. If the lock does belong to you, then the deployment will continue.
57 |
58 | ### Deployment Lock Examples 📸
59 |
60 | Here are a few examples of deployment locks in action!
61 |
62 | Lock Example:
63 |
64 | 
65 |
66 | Unlock Example:
67 |
68 | 
69 |
70 | Locking a specific environment (not just the default one):
71 |
72 | 
73 |
74 | Obtaining the lock details for development:
75 |
76 | 
77 |
78 | Remove the lock for development:
79 |
80 | 
81 |
82 | Creating a global deploy lock:
83 |
84 | 
85 |
86 | Removing the global deploy lock:
87 |
88 | 
89 |
90 | ## Actions Concurrency
91 |
92 | > Note: Using the locking mechanism included in this Action (above) is highly recommended over Actions concurrency. The section below will be included anyways should you have a valid reason to use it instead of the deploy lock features this Action provides
93 |
94 | If your workflows need some level of concurrency or locking, you can leverage the native GitHub Actions concurrency feature ([documentation](https://docs.github.com/en/actions/using-jobs/using-concurrency)) to enable this.
95 |
96 | For example, if you have two users run `.deploy` on two separate PRs at the same time, it will trigger two deployments. In some cases, this will break things and you may not want this. By using Actions concurrency, you can prevent multiple workflows from running at once
97 |
98 | The default behavior for Actions is to run the first job that was triggered and to set the other one as `pending`. If you want to cancel the other job, that can be configured as well. Below you will see an example where we setup a concurrency group which only allows one deployment at a time and cancels all other workflows triggered while our deployment is running:
99 |
100 | ```yaml
101 | concurrency:
102 | group: production
103 | cancel-in-progress: true
104 | ```
105 |
106 | ## Need More Deployment Lock Control?
107 |
108 | If you need more control over when, how, and why deployment locks are set, you can use the [github/lock](https://github.com/github/lock) Action!
109 |
110 | This Action allows you to set a lock via an issue comment, custom condition, on merges, etc. You have full control over when and how the lock is set and removed!
111 |
--------------------------------------------------------------------------------
/docs/merge-commit-strategy.md:
--------------------------------------------------------------------------------
1 | # Merge Commit Workflow Strategy
2 |
3 | > Note: This section is rather advanced and entirely optional
4 |
5 | At GitHub, we use custom logic to compare the latest deployment with the merge commit created when a pull request is merged to our default branch. This helps to save CI time, and prevent redundant deployments. If a user deploys a pull request, it succeeds, and then the pull request is merged, we will not deploy the merge commit. This is because the merge commit is the same as the latest deployment.
6 |
7 | This Action comes bundled with an alternate workflow to help facilitate exactly this. Before explaining how this works, let's first review why this might be useful.
8 |
9 | Example scenario 1:
10 |
11 | 1. You have a pull request with a branch deployment created by this Action
12 | 2. No one else except for you has created a deployment
13 | 3. You click the merge button on the pull request you just deployed
14 | 4. The "merge commit workflow strategy" is triggered on merge to your default branch
15 | 5. The workflow compares the latest deployment with the merge commit and finds they are identical
16 | 6. The workflow uses logic to exit as it does not need to deploy the merge commit since it is the same as the latest deployment
17 |
18 | Example scenario 2:
19 |
20 | 1. You have a pull request with a branch deployment created by this Action
21 | 2. You create a deployment on your pull request
22 | 3. You go to make a cup of coffee and while doing so, your teammate creates a deployment on their own (different) pull request
23 | 4. You click the merge button on the pull request you just deployed (which is now silently out of date)
24 | 5. The "merge commit workflow strategy" is triggered on merge to your default branch
25 | 6. The workflow compares the latest deployment with the merge commit and finds they are different
26 | 7. The workflow uses logic to deploy the merge commit since it is different than the latest deployment
27 |
28 | This should help explain why this strategy is useful. It helps to save CI time and prevent redundant deployments. If you are not using this strategy, you will end up deploying the merge commit even if it is the same as the latest deployment if you do a deployment every time a pull request is merged (rather common).
29 |
30 | ## Using the Merge Commit Workflow Strategy
31 |
32 | To use the advanced merge commit workflow strategy, you will need to do the following:
33 |
34 | 1. Create a new Actions workflow file in your repository that will be triggered on merge to your default branch
35 | 2. Add a job that calls the branch-deploy Action
36 | 3. Add configuration to the Action telling it to use the custom merge commit workflow strategy
37 |
38 | Below is a sample workflow with plenty of in-line comments to help you along:
39 |
40 | ```yaml
41 | name: deploy
42 | on:
43 | push:
44 | branches:
45 | - main # <-- This is the default branch for your repository
46 |
47 | jobs:
48 | deploy:
49 | if: github.event_name == 'push' # Merge commits will trigger a push event
50 | environment: production # You can configure this to whatever you call your production environment
51 | runs-on: ubuntu-latest
52 | steps:
53 | # Call the branch-deploy Action - name it something else if you want (I did here for clarity)
54 | - name: deployment check
55 | uses: github/branch-deploy@vX.X.X # replace with the latest version of this Action
56 | id: deployment-check # ensure you have an 'id' set so you can reference the output of the Action later on
57 | with:
58 | merge_deploy_mode: "true" # required, tells the Action to use the merge commit workflow strategy
59 | environment: production # optional, defaults to 'production'
60 |
61 | # Now we can conditionally 'gate' our deployment logic based on the output of the Action
62 | # If the Action returns 'true' for the 'continue' output, we can continue with our deployment logic
63 | # Otherwise, all subsequent steps will be skipped
64 |
65 | # Check out the repository
66 | - uses: actions/checkout@v3
67 | if: ${{ steps.deployment-check.outputs.continue == 'true' }} # only run if the Action returned 'true' for the 'continue' output
68 |
69 | # Do your deployment here! (However you want to do it)
70 | # This could be deployment logic via SSH, Terraform, AWS, Heroku, etc.
71 | - name: fake regular deploy
72 | if: ${{ steps.deployment-check.outputs.continue == 'true' }} # only run if the Action returned 'true' for the 'continue' output
73 | run: echo "I am doing a fake regular deploy"
74 | ```
75 |
--------------------------------------------------------------------------------
/docs/usage.md:
--------------------------------------------------------------------------------
1 | # Branch Deploy Usage Guide 📚
2 |
3 | This document is a quick guide / cheatsheet for using the `branch-deploy` Action
4 |
5 | > This guide assumes default configuration options
6 |
7 | ## Help 🗨️
8 |
9 | To view your available commands, environment targets, and how your workflow is specifically configured, you can run the following command:
10 |
11 | `.help`
12 |
13 | ## Deployment 🚀
14 |
15 | Deployments respect your repository's branch protection settings. You can trigger either a regular or noop deployment:
16 |
17 | - `.deploy` - Triggers a regular deployment using the default environment (think "Terraform apply" for example)
18 | - `.deploy noop` - Triggers a noop deployment (think "Terraform plan" for example)
19 | - `.deploy ` - Triggers a deployment for the specified environment
20 | - `.deploy noop ` - Triggers a noop deployment for the specified environment
21 | - `.deploy ` - Trigger a rollback deploy to your stable branch (main, master, etc)
22 |
23 | ## Deployment Locks 🔒
24 |
25 | If you need to lock deployments so that only you can trigger them, you can use the following set of commands:
26 |
27 | - `.lock` - Locks deployments (sticky) so that only you can trigger them - uses the default environment (usually production)
28 | - `.lock --reason ` - Lock deployments with a reason (sticky) - uses the default environment (usually production)
29 | - `.unlock` - Removes the current deployment lock (if one exists) - uses the default environment (usually production)
30 | - `.lock --info` - Displays info about the current deployment lock if one exists - uses the default environment (usually production)
31 | - `.wcid` - An alias for `.lock --info`, it means "where can I deploy" - uses the default environment (usually production)
32 | - `.lock ` - Locks deployments (sticky) so that only you can trigger them - uses the specified environment
33 | - `.lock --reason ` - Lock deployments with a reason (sticky) - uses the specified environment
34 | - `.lock --info` - Displays info about the current deployment lock if one exists - uses the specified environment
35 | - `.unlock ` - Removes the current deployment lock (if one exists) - uses the specified environment
36 | - `.lock --global` - Locks deployments globally (sticky) so that only you can trigger them - blocks all environments
37 | - `.lock --global --reason ` - Lock deployments globally with a reason (sticky) - blocks all environments
38 | - `.unlock --global` - Removes the current global deployment lock (if one exists)
39 |
40 | > Note: A deployment lock blocks deploys for all environments. **sticky** locks will also persist until someone removes them with `.unlock`
41 |
42 | It should be noted that anytime you use a `.lock`, `.unlock`, or `.lock --details` command without an environment, it will use the default environment target. This is usually `production` and can be configured in your branch-deploy workflow definition.
43 |
44 | ## Deployment Rollbacks 🔙
45 |
46 | If something goes wrong and you need to redeploy the main/master/base branch of your repository, you can use the following set of commands:
47 |
48 | - `.deploy main` - Rolls back to the `main` branch in production
49 | - `.deploy main to ` - Rolls back to the `main` branch in the specified environment
50 |
51 | > Note: The `stable_branch` option can be configured in your branch-deploy workflow definition. By default it is the `main` branch but it can be changed to `master` or any other branch name.
52 |
53 | ## Environment Targets 🏝️
54 |
55 | Environment targets are used to target specific environments for deployments. These are specifically defined in the Actions workflow and could be anything you want. Common examples are `production`, `staging`, `development`, etc.
56 |
57 | To view what environments are available in your workflow, you can run the `.help` command.
58 |
59 | `.deploy` will always use the default environment target unless you specify one. If you are ever unsure what environment to use, please contact your team member who setup the workflow.
60 |
61 | > Note: You can learn more about environment targets [here](https://github.com/github/branch-deploy#environment-targets)
62 |
63 | ## Deployment Permissions 🔑
64 |
65 | In order to run any branch deployment commands, you need the following permissions:
66 |
67 | - `write` or `admin` permissions to the repository
68 | - You must either be the owner of the current deployment lock, or there must be no deployment lock
69 |
70 | ## Example Workflow 📑
71 |
72 | An example workflow for using this Action might look like this:
73 |
74 | > All commands assume the default environment target of `production`
75 |
76 | 1. A user creates an awesome new feature for their website
77 | 2. The user creates a branch, commits their changes, and pushes the branch to GitHub
78 | 3. The user opens a pull request to the `main` branch from their feature branch
79 | 4. Once CI is passing and the user has the proper reviews on their pull request, they can continue
80 | 5. The user grabs the deployment lock as they need an hour or two for validating their change -> `.lock`
81 | 6. The lock is claimed and now only the user who claimed it can deploy
82 | 7. The user runs `.deploy noop` to get a preview of their changes
83 | 8. All looks good so the user runs `.deploy` and ships their code to production from their branch
84 |
85 | > If anything goes wrong, the user can run `.deploy main` to rollback to the `main` branch
86 |
87 | 9. After an hour or so, all looks good so they merge their changes to the `main` branch
88 | 10. Upon merging, they comment on their merged pull request `.unlock` to remove the lock
89 | 11. Done!
90 |
--------------------------------------------------------------------------------
/events/issue_comment_deploy.json:
--------------------------------------------------------------------------------
1 | {
2 | "comment": {
3 | "body": ".deploy"
4 | },
5 | "issue": {
6 | "pull_request": {},
7 | "number": 1
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/events/issue_comment_deploy_main.json:
--------------------------------------------------------------------------------
1 | {
2 | "comment": {
3 | "body": ".deploy main"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/events/issue_comment_deploy_noop.json:
--------------------------------------------------------------------------------
1 | {
2 | "comment": {
3 | "body": ".deploy noop"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "branch-deploy-action",
3 | "version": "0.0.0",
4 | "private": true,
5 | "description": "Enabling Branch Deployments through IssueOps with GitHub Actions",
6 | "main": "lib/main.js",
7 | "scripts": {
8 | "format": "prettier --write '**/*.js'",
9 | "format-check": "prettier --check '**/*.js'",
10 | "lint": "eslint src/**/*.js",
11 | "package": "ncc build src/main.js -o dist --source-map --license licenses.txt",
12 | "test": "(jest && make-coverage-badge --output-path ./badges/coverage.svg) || make-coverage-badge --output-path ./badges/coverage.svg",
13 | "ci-test": "jest",
14 | "all": "npm run format && npm run lint && npm run package",
15 | "bundle": "npm run format && npm run package",
16 | "act": "npm run format && npm run package && act issue_comment -e events/issue_comment_deploy.json -s GITHUB_TOKEN=faketoken -j test"
17 | },
18 | "repository": {
19 | "type": "git",
20 | "url": "git+https://github.com/github/branch-deploy.git"
21 | },
22 | "keywords": [
23 | "actions",
24 | "issueops",
25 | "deployment",
26 | "github"
27 | ],
28 | "author": "Grant Birkinbine",
29 | "license": "MIT",
30 | "dependencies": {
31 | "@actions/core": "^1.10.0",
32 | "@actions/github": "^5.0.1",
33 | "dedent-js": "^1.0.1",
34 | "github-username-regex": "^1.0.0"
35 | },
36 | "jest": {
37 | "coverageReporters": [
38 | "json-summary",
39 | "text",
40 | "lcov"
41 | ],
42 | "collectCoverage": true,
43 | "collectCoverageFrom": [
44 | "./src/**"
45 | ],
46 | "coverageThreshold": {
47 | "global": {
48 | "lines": 100
49 | }
50 | }
51 | },
52 | "devDependencies": {
53 | "@babel/plugin-transform-modules-commonjs": "^7.17.9",
54 | "@octokit/rest": "^19.0.7",
55 | "@types/node": "^16.10.5",
56 | "@vercel/ncc": "^0.36.1",
57 | "eslint": "^7.32.0",
58 | "eslint-plugin-jest": "^25.3.2",
59 | "jest": "^27.2.5",
60 | "js-yaml": "^4.1.0",
61 | "prettier": "2.5.1",
62 | "make-coverage-badge": "^1.2.0",
63 | "@babel/preset-env": "^7.17.10",
64 | "babel-core": "^6.26.3",
65 | "babel-jest": "^28.1.0"
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/script/release:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Usage:
4 | # script/release
5 |
6 | # COLORS
7 | OFF='\033[0m'
8 | RED='\033[0;31m'
9 | GREEN='\033[0;32m'
10 | BLUE='\033[0;34m'
11 |
12 | latest_tag=$(git describe --tags $(git rev-list --tags --max-count=1))
13 | echo -e "The latest release tag is: ${BLUE}${latest_tag}${OFF}"
14 | read -p 'New Release Tag (vX.X.X format): ' new_tag
15 |
16 | tag_regex='^v\d\.\d\.\d$'
17 | echo "$new_tag" | grep -P -q $tag_regex
18 |
19 | if [[ $? -ne 0 ]]; then
20 | echo "Tag: $new_tag is valid"
21 | fi
22 |
23 | git tag -a $new_tag -m "$new_tag Release"
24 |
25 | echo -e "${GREEN}OK${OFF} - Tagged: $new_tag"
26 |
27 | git push --tags
28 |
29 | echo -e "${GREEN}OK${OFF} - Tags pushed to remote!"
30 | echo -e "${GREEN}DONE${OFF}"
31 |
--------------------------------------------------------------------------------
/src/functions/action-status.js:
--------------------------------------------------------------------------------
1 | // Default failure reaction
2 | const thumbsDown = '-1'
3 | // Default success reaction
4 | const rocket = 'rocket'
5 | // Alt success reaction
6 | const thumbsUp = '+1'
7 |
8 | // Helper function to add a status update for the action that is running a branch deployment
9 | // It also updates the original comment with a reaction depending on the status of the deployment
10 | // :param context: The context of the action
11 | // :param octokit: The octokit object
12 | // :param reactionId: The id of the original reaction added to our trigger comment (Integer)
13 | // :param message: The message to be added to the action status (String)
14 | // :param success: Boolean indicating whether the deployment was successful (Boolean)
15 | // :param altSuccessReaction: Boolean indicating whether to use the alternate success reaction (Boolean)
16 | // :returns: Nothing
17 | export async function actionStatus(
18 | context,
19 | octokit,
20 | reactionId,
21 | message,
22 | success,
23 | altSuccessReaction
24 | ) {
25 | // check if message is null or empty
26 | if (!message || message.length === 0) {
27 | const log_url = `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.GITHUB_RUN_ID}`
28 | message = 'Unknown error, [check logs](' + log_url + ') for more details.'
29 | }
30 |
31 | // add a comment to the issue with the message
32 | await octokit.rest.issues.createComment({
33 | ...context.repo,
34 | issue_number: context.issue.number,
35 | body: message
36 | })
37 |
38 | // Select the reaction to add to the issue_comment
39 | var reaction
40 | if (success) {
41 | if (altSuccessReaction) {
42 | reaction = thumbsUp
43 | } else {
44 | reaction = rocket
45 | }
46 | } else {
47 | reaction = thumbsDown
48 | }
49 |
50 | // add a reaction to the issue_comment to indicate success or failure
51 | await octokit.rest.reactions.createForIssueComment({
52 | ...context.repo,
53 | comment_id: context.payload.comment.id,
54 | content: reaction
55 | })
56 |
57 | // remove the initial reaction on the IssueOp comment that triggered this action
58 | await octokit.rest.reactions.deleteForIssueComment({
59 | ...context.repo,
60 | comment_id: context.payload.comment.id,
61 | reaction_id: reactionId
62 | })
63 | }
64 |
--------------------------------------------------------------------------------
/src/functions/admin.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import * as github from '@actions/github'
3 | import githubUsernameRegex from 'github-username-regex'
4 |
5 | // Helper function to check if a user exists in an org team
6 | // :param actor: The user to check
7 | // :param orgTeams: An array of org/team names
8 | // :returns: True if the user is in the org team, false otherwise
9 | async function orgTeamCheck(actor, orgTeams) {
10 | // This pat needs org read permissions if you are using org/teams to define admins
11 | const adminsPat = core.getInput('admins_pat')
12 |
13 | // If no admin_pat is provided, then we cannot check for org team memberships
14 | if (!adminsPat || adminsPat.length === 0 || adminsPat === 'false') {
15 | core.warning(
16 | 'No admins_pat provided, skipping admin check for org team membership'
17 | )
18 | return false
19 | }
20 |
21 | // Create a new octokit client with the admins_pat
22 | const octokit = github.getOctokit(adminsPat)
23 |
24 | // Loop through all org/team names
25 | for (const orgTeam of orgTeams) {
26 | // Split the org/team name into org and team
27 | var [org, team] = orgTeam.split('/')
28 |
29 | try {
30 | // Make an API call to get the org id
31 | const orgData = await octokit.rest.orgs.get({
32 | org: org
33 | })
34 | const orgId = orgData.data.id
35 |
36 | // Make an API call to get the team id
37 | const teamData = await octokit.rest.teams.getByName({
38 | org: org,
39 | team_slug: team
40 | })
41 | const teamId = teamData.data.id
42 |
43 | // This API call checks if the user exists in the team for the given org
44 | const result = await octokit.request(
45 | `GET /organizations/${orgId}/team/${teamId}/members/${actor}`
46 | )
47 |
48 | // If the status code is a 204, the user is in the team
49 | if (result.status === 204) {
50 | core.debug(`${actor} is in ${orgTeam}`)
51 | return true
52 | // If some other status code occured, return false and output a warning
53 | } else {
54 | core.warning(`non 204 response from org team check: ${result.status}`)
55 | }
56 | } catch (error) {
57 | // If any of the API calls returns a 404, the user is not in the team
58 | if (error.status === 404) {
59 | core.debug(`${actor} is not a member of the ${orgTeam} team`)
60 | // If some other error occured, output a warning
61 | } else {
62 | core.warning(`Error checking org team membership: ${error}`)
63 | }
64 | }
65 | }
66 |
67 | // If we get here, the user is not in any of the org teams
68 | return false
69 | }
70 |
71 | // Helper function to check if a user is set as an admin for branch-deployments
72 | // :param context: The GitHub Actions event context
73 | // :returns: true if the user is an admin, false otherwise (Boolean)
74 | export async function isAdmin(context) {
75 | // Get the admins string from the action inputs
76 | const admins = core.getInput('admins')
77 |
78 | core.debug(`raw admins value: ${admins}`)
79 |
80 | // Sanitized the input to remove any whitespace and split into an array
81 | const adminsSanitized = admins
82 | .split(',')
83 | .map(admin => admin.trim().toLowerCase())
84 |
85 | // loop through admins
86 | var handles = []
87 | var orgTeams = []
88 | adminsSanitized.forEach(admin => {
89 | // If the item contains a '/', then it is a org/team
90 | if (admin.includes('/')) {
91 | orgTeams.push(admin)
92 | }
93 | // Otherwise, it is a github handle
94 | else {
95 | // Check if the github handle is valid
96 | if (githubUsernameRegex.test(admin)) {
97 | // Add the handle to the list of handles and remove @ from the start of the handle
98 | handles.push(admin.replace('@', ''))
99 | } else {
100 | core.debug(
101 | `${admin} is not a valid GitHub username... skipping admin check`
102 | )
103 | }
104 | }
105 | })
106 |
107 | // Check if the user is in the admin handle list
108 | if (handles.includes(context.actor.toLowerCase())) {
109 | core.debug(`${context.actor} is an admin via handle reference`)
110 | return true
111 | }
112 |
113 | // Check if the user is in the org/team list
114 | if (orgTeams.length > 0) {
115 | const result = await orgTeamCheck(context.actor, orgTeams)
116 | if (result) {
117 | core.debug(`${context.actor} is an admin via org team reference`)
118 | return true
119 | }
120 | }
121 |
122 | // If we get here, the user is not an admin
123 | core.debug(`${context.actor} is not an admin`)
124 | return false
125 | }
126 |
--------------------------------------------------------------------------------
/src/functions/context-check.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 |
3 | // A simple function that checks the event context to make sure it is valid
4 | // :param context: The GitHub Actions event context
5 | // :returns: Boolean - true if the context is valid, false otherwise
6 | export async function contextCheck(context) {
7 | // Get the PR event context
8 | var pr
9 | try {
10 | pr = context.payload.issue.pull_request
11 | } catch (error) {
12 | throw new Error(`Could not get PR event context: ${error}`)
13 | }
14 |
15 | // If the context is not valid, return false
16 | if (context.eventName !== 'issue_comment' || pr == null || pr == undefined) {
17 | core.saveState('bypass', 'true')
18 | core.warning(
19 | 'This Action can only be run in the context of a pull request comment'
20 | )
21 | return false
22 | }
23 |
24 | // If the context is valid, return true
25 | return true
26 | }
27 |
--------------------------------------------------------------------------------
/src/functions/deployment.js:
--------------------------------------------------------------------------------
1 | // Helper function to add deployment statuses to a PR / ref
2 | // :param octokit: The octokit client
3 | // :param context: The GitHub Actions event context
4 | // :param ref: The ref to add the deployment status to
5 | // :param state: The state of the deployment
6 | // :param deploymentId: The id of the deployment
7 | // :param environment: The environment of the deployment
8 | // :param environment_url: The environment url of the deployment (default '')
9 | // :returns: The result of the deployment status creation (Object)
10 | export async function createDeploymentStatus(
11 | octokit,
12 | context,
13 | ref,
14 | state,
15 | deploymentId,
16 | environment,
17 | environment_url = null
18 | ) {
19 | // Get the owner and the repo from the context
20 | const {owner, repo} = context.repo
21 |
22 | const {data: result} = await octokit.rest.repos.createDeploymentStatus({
23 | owner: owner,
24 | repo: repo,
25 | ref: ref,
26 | deployment_id: deploymentId,
27 | state: state,
28 | log_url: `${process.env.GITHUB_SERVER_URL}/${owner}/${repo}/actions/runs/${context.runId}`,
29 | environment: environment,
30 | environment_url: environment_url
31 | })
32 |
33 | return result
34 | }
35 |
--------------------------------------------------------------------------------
/src/functions/environment-targets.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import dedent from 'dedent-js'
3 | import {actionStatus} from './action-status'
4 | import {LOCK_METADATA} from './lock-metadata'
5 |
6 | // Helper function to that does environment checks specific to branch deploys
7 | // :param environment_targets_sanitized: The list of environment targets
8 | // :param body: The body of the comment
9 | // :param trigger: The trigger used to initiate the deployment
10 | // :param noop_trigger: The trigger used to initiate a noop deployment
11 | // :param stable_branch: The stable branch
12 | // :param environment: The default environment
13 | // :returns: The environment target if found, false otherwise
14 | async function onDeploymentChecks(
15 | environment_targets_sanitized,
16 | body,
17 | trigger,
18 | noop_trigger,
19 | stable_branch,
20 | environment
21 | ) {
22 | // Loop through all the environment targets to see if an explicit target is being used
23 | for (const target of environment_targets_sanitized) {
24 | // If the body on a branch deploy contains the target
25 | if (body.replace(trigger, '').trim() === target) {
26 | core.debug(`Found environment target for branch deploy: ${target}`)
27 | return target
28 | }
29 | // If the body on a noop trigger contains the target
30 | else if (body.replace(`${trigger} ${noop_trigger}`, '').trim() === target) {
31 | core.debug(`Found environment target for noop trigger: ${target}`)
32 | return target
33 | }
34 | // If the body with 'to ' contains the target on a branch deploy
35 | else if (body.replace(trigger, '').trim() === `to ${target}`) {
36 | core.debug(
37 | `Found environment target for branch deploy (with 'to'): ${target}`
38 | )
39 | return target
40 | }
41 | // If the body with 'to ' contains the target on a noop trigger
42 | else if (
43 | body.replace(`${trigger} ${noop_trigger}`, '').trim() === `to ${target}`
44 | ) {
45 | core.debug(
46 | `Found environment target for noop trigger (with 'to'): ${target}`
47 | )
48 | return target
49 | }
50 | // If the body with 'to ' contains the target on a stable branch deploy
51 | else if (
52 | body.replace(`${trigger} ${stable_branch}`, '').trim() === `to ${target}`
53 | ) {
54 | core.debug(
55 | `Found environment target for stable branch deploy (with 'to'): ${target}`
56 | )
57 | return target
58 | }
59 | // If the body on a stable branch deploy contains the target
60 | if (body.replace(`${trigger} ${stable_branch}`, '').trim() === target) {
61 | core.debug(`Found environment target for stable branch deploy: ${target}`)
62 | return target
63 | }
64 | // If the body matches the trigger phrase exactly, just use the default environment
65 | else if (body.trim() === trigger) {
66 | core.debug('Using default environment for branch deployment')
67 | return environment
68 | }
69 | // If the body matches the noop trigger phrase exactly, just use the default environment
70 | else if (body.trim() === `${trigger} ${noop_trigger}`) {
71 | core.debug('Using default environment for noop trigger')
72 | return environment
73 | }
74 | // If the body matches the stable branch phrase exactly, just use the default environment
75 | else if (body.trim() === `${trigger} ${stable_branch}`) {
76 | core.debug('Using default environment for stable branch deployment')
77 | return environment
78 | }
79 | }
80 |
81 | // If we get here, then no valid environment target was found
82 | return false
83 | }
84 |
85 | // Helper function to that does environment checks specific to lock/unlock commands
86 | // :param environment_targets_sanitized: The list of environment targets
87 | // :param body: The body of the comment
88 | // :param lock_trigger: The trigger used to initiate the lock command
89 | // :param unlock_trigger: The trigger used to initiate the unlock command
90 | // :param environment: The default environment from the Actions inputs
91 | // :returns: The environment target if found, false otherwise
92 | async function onLockChecks(
93 | environment_targets_sanitized,
94 | body,
95 | lock_trigger,
96 | unlock_trigger,
97 | environment
98 | ) {
99 | // if the body contains the globalFlag, exit right away as environments are not relevant
100 | const globalFlag = core.getInput('global_lock_flag').trim()
101 | if (body.includes(globalFlag)) {
102 | core.debug('Global lock flag found in environment target check')
103 | return 'GLOBAL_REQUEST'
104 | }
105 |
106 | // remove any lock flags from the body
107 | LOCK_METADATA.lockInfoFlags.forEach(flag => {
108 | body = body.replace(flag, '').trim()
109 | })
110 |
111 | // remove the --reason from the body if it exists
112 | if (body.includes('--reason')) {
113 | core.debug(
114 | `'--reason' found in comment body: ${body} - attempting to remove for environment checks`
115 | )
116 | body = body.split('--reason')[0]
117 | core.debug(`comment body after '--reason' removal: ${body}`)
118 | }
119 |
120 | // Get the lock info alias from the action inputs
121 | const lockInfoAlias = core.getInput('lock_info_alias')
122 |
123 | // if the body matches the lock trigger exactly, just use the default environment
124 | if (body.trim() === lock_trigger.trim()) {
125 | core.debug('Using default environment for lock request')
126 | return environment
127 | }
128 |
129 | // if the body matches the unlock trigger exactly, just use the default environment
130 | if (body.trim() === unlock_trigger.trim()) {
131 | core.debug('Using default environment for unlock request')
132 | return environment
133 | }
134 |
135 | // if the body matches the lock info alias exactly, just use the default environment
136 | if (body.trim() === lockInfoAlias.trim()) {
137 | core.debug('Using default environment for lock info request')
138 | return environment
139 | }
140 |
141 | // Loop through all the environment targets to see if an explicit target is being used
142 | for (const target of environment_targets_sanitized) {
143 | // If the body on a branch deploy contains the target
144 | if (body.replace(lock_trigger, '').trim() === target) {
145 | core.debug(`Found environment target for lock request: ${target}`)
146 | return target
147 | } else if (body.replace(unlock_trigger, '').trim() === target) {
148 | core.debug(`Found environment target for unlock request: ${target}`)
149 | return target
150 | } else if (body.replace(lockInfoAlias, '').trim() === target) {
151 | core.debug(`Found environment target for lock info request: ${target}`)
152 | return target
153 | }
154 | }
155 |
156 | // If we get here, then no valid environment target was found
157 | return false
158 | }
159 |
160 | // Helper function to find the environment URL for a given environment target (if it exists)
161 | // :param environment: The environment target
162 | // :param environment_urls: The environment URLs from the action inputs
163 | // :returns: The environment URL if found, an empty string otherwise
164 | async function findEnvironmentUrl(environment, environment_urls) {
165 | // The structure: "|,|,etc"
166 |
167 | // If the environment URLs are empty, just return an empty string
168 | if (environment_urls === null || environment_urls.trim() === '') {
169 | return null
170 | }
171 |
172 | // Split the environment URLs into an array
173 | const environment_urls_array = environment_urls.trim().split(',')
174 |
175 | // Loop through the array and find the environment URL for the given environment target
176 | for (const environment_url of environment_urls_array) {
177 | const environment_url_array = environment_url.trim().split('|')
178 | if (environment_url_array[0] === environment) {
179 | const environment_url = environment_url_array[1]
180 |
181 | // if the environment url exactly matches 'disabled' then return null
182 | if (environment_url === 'disabled') {
183 | core.info(`environment url for ${environment} is explicitly disabled`)
184 | core.saveState('environment_url', 'null')
185 | core.setOutput('environment_url', 'null')
186 | return null
187 | }
188 |
189 | // if the environment url does not match the http(s) schema, log a warning and continue
190 | if (!environment_url.match(/^https?:\/\//)) {
191 | core.warning(
192 | `environment url does not match http(s) schema: ${environment_url}`
193 | )
194 | continue
195 | }
196 |
197 | core.saveState('environment_url', environment_url)
198 | core.setOutput('environment_url', environment_url)
199 | core.info(`environment url detected: ${environment_url}`)
200 | return environment_url
201 | }
202 | }
203 |
204 | // If we get here, then no environment URL was found
205 | core.warning(
206 | `no valid environment URL found for environment: ${environment} - setting environment URL to 'null' - please check your 'environment_urls' input`
207 | )
208 | core.saveState('environment_url', 'null')
209 | core.setOutput('environment_url', 'null')
210 | return null
211 | }
212 |
213 | // A simple function that checks if an explicit environment target is being used
214 | // :param environment: The default environment from the Actions inputs
215 | // :param body: The comment body
216 | // :param trigger: The trigger prefix
217 | // :param alt_trigger: Usually the noop trigger prefix
218 | // :param stable_branch: The stable branch (only used for branch deploys)
219 | // :param context: The context of the Action
220 | // :param octokit: The Octokit instance
221 | // :param reactionId: The ID of the initial comment reaction (Integer)
222 | // :param lockChecks: Whether or not this is a lock/unlock command (Boolean)
223 | // :param environment_urls: The environment URLs from the action inputs
224 | // :returns: An object containing the environment target and environment URL
225 | export async function environmentTargets(
226 | environment,
227 | body,
228 | trigger,
229 | alt_trigger,
230 | stable_branch,
231 | context,
232 | octokit,
233 | reactionId,
234 | lockChecks = false,
235 | environment_urls = null
236 | ) {
237 | // Get the environment targets from the action inputs
238 | const environment_targets = core.getInput('environment_targets')
239 |
240 | // Sanitized the input to remove any whitespace and split into an array
241 | const environment_targets_sanitized = environment_targets
242 | .split(',')
243 | .map(target => target.trim())
244 |
245 | // convert the environment targets into an array joined on ,
246 | const environment_targets_joined = environment_targets_sanitized.join(',')
247 |
248 | // If lockChecks is set to true, this request is for either a lock/unlock command to check the body for an environment target
249 | if (lockChecks === true) {
250 | const environmentDetected = await onLockChecks(
251 | environment_targets_sanitized,
252 | body,
253 | trigger,
254 | alt_trigger,
255 | environment
256 | )
257 | if (environmentDetected !== false) {
258 | return {environment: environmentDetected, environmentUrl: null}
259 | }
260 |
261 | // If we get here, then no valid environment target was found
262 | const message = dedent(`
263 | No matching environment target found. Please check your command and try again. You can read more about environment targets in the README of this Action.
264 |
265 | > The following environment targets are available: \`${environment_targets_joined}\`
266 | `)
267 | core.warning(message)
268 | core.saveState('bypass', 'true')
269 |
270 | // Return the action status as a failure
271 | await actionStatus(
272 | context,
273 | octokit,
274 | reactionId,
275 | `### ⚠️ Cannot proceed with lock/unlock request\n\n${message}`
276 | )
277 |
278 | return {environment: false, environmentUrl: null}
279 | }
280 |
281 | // If lockChecks is set to false, this request is for a branch deploy to check the body for an environment target
282 | if (lockChecks === false) {
283 | const environmentDetected = await onDeploymentChecks(
284 | environment_targets_sanitized,
285 | body,
286 | trigger,
287 | alt_trigger,
288 | stable_branch,
289 | environment
290 | )
291 |
292 | // If no environment target was found, let the user know via a comment and return false
293 | if (environmentDetected === false) {
294 | const message = dedent(`
295 | No matching environment target found. Please check your command and try again. You can read more about environment targets in the README of this Action.
296 |
297 | > The following environment targets are available: \`${environment_targets_joined}\`
298 | `)
299 | core.warning(message)
300 | core.saveState('bypass', 'true')
301 |
302 | // Return the action status as a failure
303 | await actionStatus(
304 | context,
305 | octokit,
306 | reactionId,
307 | `### ⚠️ Cannot proceed with deployment\n\n${message}`
308 | )
309 | return {environment: false, environmentUrl: null}
310 | }
311 |
312 | // Attempt to get the environment URL from the environment_urls input using the environment target as the key
313 | const environmentUrl = await findEnvironmentUrl(
314 | environmentDetected,
315 | environment_urls
316 | )
317 |
318 | // Return the environment target
319 | return {environment: environmentDetected, environmentUrl: environmentUrl}
320 | }
321 | }
322 |
--------------------------------------------------------------------------------
/src/functions/help.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import dedent from 'dedent-js'
3 | import {actionStatus} from './action-status'
4 |
5 | const defaultSpecificMessage = ''
6 | const usageGuideLink =
7 | 'https://github.com/github/branch-deploy/blob/main/docs/usage.md'
8 |
9 | export async function help(octokit, context, reactionId, inputs) {
10 | var update_branch_message = defaultSpecificMessage
11 | if (inputs.update_branch.trim() === 'warn') {
12 | update_branch_message =
13 | 'This Action will warn if the branch is out of date with the base branch'
14 | } else if (inputs.update_branch === 'force') {
15 | update_branch_message =
16 | 'This Action will force update the branch to the base branch if it is out of date'
17 | } else if (inputs.update_branch === 'disabled') {
18 | update_branch_message =
19 | 'This Action will not update the branch to the base branch before deployment'
20 | }
21 |
22 | var required_contexts_message = defaultSpecificMessage
23 | if (inputs.required_contexts.trim() === 'false') {
24 | required_contexts_message =
25 | 'There are no designated required contexts for this Action (default and suggested)'
26 | } else {
27 | required_contexts_message = `There are required contexts designated for this Action`
28 | }
29 |
30 | var skip_ci_message = defaultSpecificMessage
31 | if (inputs.skipCi.trim() !== '') {
32 | skip_ci_message = `This Action will not require passing CI for the environments specified`
33 | } else {
34 | inputs.skipCi = 'false'
35 | skip_ci_message = `This Action will require passing CI for all environments`
36 | }
37 |
38 | var skip_reviews_message = defaultSpecificMessage
39 | if (inputs.skipReviews.trim() !== '') {
40 | skip_reviews_message = `This Action will not require passing reviews for the environments specified`
41 | } else {
42 | inputs.skipReviews = 'false'
43 | skip_reviews_message = `This Action will require passing reviews for all environments`
44 | }
45 |
46 | var admins_message = defaultSpecificMessage
47 | if (inputs.admins.trim() === 'false') {
48 | admins_message = `This Action has no designated admins (default)`
49 | } else {
50 | admins_message = `This Action will allow the listed admins to bypass pull request reviews before deployment`
51 | }
52 |
53 | // Construct the message to add to the issue comment
54 | const comment = dedent(`
55 | ## 📚 Branch Deployment Help
56 |
57 | This help message was automatically generated based on the inputs provided to this Action.
58 |
59 | ### 💻 Available Commands
60 |
61 | - \`${inputs.help_trigger}\` - Show this help message
62 | - \`${inputs.trigger}\` - Deploy this branch to the \`${
63 | inputs.environment
64 | }\` environment
65 | - \`${inputs.trigger} ${inputs.stable_branch}\` - Rollback the \`${
66 | inputs.environment
67 | }\` environment to the \`${inputs.stable_branch}\` branch
68 | - \`${inputs.trigger} ${
69 | inputs.noop_trigger
70 | }\` - Deploy this branch to the \`${
71 | inputs.environment
72 | }\` environment in noop mode
73 | - \`${
74 | inputs.lock_trigger
75 | }\` - Obtain the deployment lock (will persist until the lock is released)
76 | - \`${
77 | inputs.lock_trigger
78 | } --reason \` - Obtain the deployment lock with a reason (will persist until the lock is released)
79 | - \`${
80 | inputs.lock_trigger
81 | } \` - Obtain the deployment lock for the specified environment (will persist until the lock is released)
82 | - \`${
83 | inputs.lock_trigger
84 | } --reason \` - Obtain the deployment lock for the specified environment with a reason (will persist until the lock is released)
85 | - \`${inputs.lock_trigger} ${
86 | inputs.global_lock_flag
87 | }\` - Obtain a global deployment lock (will persist until the lock is released) - Blocks all environments
88 | - \`${inputs.lock_trigger} ${
89 | inputs.global_lock_flag
90 | } --reason \` - Obtain a global deployment lock with a reason (will persist until the lock is released) - Blocks all environments
91 | - \`${inputs.unlock_trigger}\` - Release the deployment lock (if one exists)
92 | - \`${
93 | inputs.unlock_trigger
94 | } \` - Release the deployment lock for the specified environment (if one exists)
95 | - \`${inputs.unlock_trigger} ${
96 | inputs.global_lock_flag
97 | }\` - Release the global deployment lock (if one exists)
98 | - \`${
99 | inputs.lock_trigger
100 | } --details\` - Show information about the current deployment lock (if one exists)
101 | - \`${
102 | inputs.lock_trigger
103 | } --details\` - Get information about the current deployment lock for the specified environment (if one exists)
104 | - \`${inputs.lock_trigger} ${
105 | inputs.global_lock_flag
106 | } --details\` - Show information about the current global deployment lock (if one exists)
107 | - \`${inputs.lock_info_alias}\` - Alias for \`${
108 | inputs.lock_trigger
109 | } --details\`
110 |
111 | ### 🌍 Environments
112 |
113 | These are the available environments for this Action as defined by the inputs provided to this Action.
114 |
115 | > Note: Just because an environment is listed here does not mean it is available for deployment
116 |
117 | - \`${inputs.environment}\` - The default environment for this Action
118 | - \`${
119 | inputs.production_environment
120 | }\` - The environment that is considered "production"
121 | - \`${
122 | inputs.environment_targets
123 | }\` - The list of environments that can be targeted for deployment
124 |
125 | ### 🔭 Example Commands
126 |
127 | The following set of examples use this Action's inputs to show you how to use the commands.
128 |
129 | - \`${inputs.trigger}\` - Deploy this branch to the \`${
130 | inputs.environment
131 | }\` environment
132 | - \`${inputs.trigger} ${inputs.stable_branch}\` - Rollback the \`${
133 | inputs.environment
134 | }\` environment to the \`${inputs.stable_branch}\` branch
135 | - \`${inputs.trigger} ${
136 | inputs.noop_trigger
137 | }\` - Deploy this branch to the \`${
138 | inputs.environment
139 | }\` environment in noop mode
140 | - \`${inputs.trigger} to <${inputs.environment_targets.replaceAll(
141 | ',',
142 | '|'
143 | )}>\` - Deploy this branch to the specified environment (note: the \`to\` keyword is optional)
144 | - \`${inputs.lock_trigger} <${inputs.environment_targets.replaceAll(
145 | ',',
146 | '|'
147 | )}>\` - Obtain the deployment lock for the specified environment
148 | - \`${inputs.unlock_trigger} <${inputs.environment_targets.replaceAll(
149 | ',',
150 | '|'
151 | )}>\` - Release the deployment lock for the specified environment
152 | - \`${inputs.lock_trigger} <${inputs.environment_targets.replaceAll(
153 | ',',
154 | '|'
155 | )}> --details\` - Get information about the deployment lock for the specified environment
156 |
157 | ### ⚙️ Configuration
158 |
159 | The following configuration options have been defined for this Action:
160 |
161 | - \`reaction: ${
162 | inputs.reaction
163 | }\` - The GitHub reaction icon to add to the deployment comment when a deployment is triggered
164 | - \`update_branch: ${inputs.update_branch}\` - ${update_branch_message}
165 | - \`required_contexts: ${
166 | inputs.required_contexts
167 | }\` - ${required_contexts_message}
168 | - \`allowForks: ${inputs.allowForks}\` - This Action will ${
169 | inputs.allowForks === 'true' ? 'run' : 'not run'
170 | } on forked repositories
171 | - \`prefixOnly: ${inputs.prefixOnly}\` - This Action will ${
172 | inputs.prefixOnly === 'true'
173 | ? 'only run if the comment starts with the trigger'
174 | : 'run if the comment contains the trigger anywhere in the comment body'
175 | }
176 | - \`skipCi: ${inputs.skipCi}\` - ${skip_ci_message}
177 | - \`skipReviews: ${inputs.skipReviews}\` - ${skip_reviews_message}
178 | - \`admins: ${inputs.admins}\` - ${admins_message}
179 |
180 | ---
181 |
182 | > View the full usage guide [here](${usageGuideLink}) for additional help
183 | `)
184 |
185 | core.debug(comment)
186 |
187 | // Put the help comment on the pull request
188 | await actionStatus(
189 | context,
190 | octokit,
191 | reactionId,
192 | comment,
193 | true, // success is true
194 | true // thumbs up instead of rocket
195 | )
196 | }
197 |
--------------------------------------------------------------------------------
/src/functions/identical-commit-check.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 |
3 | // Helper function to check if the current deployment's ref is identical to the merge commit
4 | // :param octokit: the authenticated octokit instance
5 | // :param context: the context object
6 | // :param environment: the environment to check
7 | // :return: true if the current deployment's ref is identical to the merge commit, false otherwise
8 | export async function identicalCommitCheck(octokit, context, environment) {
9 | // get the owner and the repo from the context
10 | const {owner, repo} = context.repo
11 |
12 | // find the default branch
13 | const {data: repoData} = await octokit.rest.repos.get({
14 | owner,
15 | repo
16 | })
17 | const defaultBranchName = repoData.default_branch
18 | core.debug(`default branch name: ${defaultBranchName}`)
19 |
20 | // get the latest commit on the default branch of the repo
21 | const {data: defaultBranchData} = await octokit.rest.repos.getBranch({
22 | owner,
23 | repo,
24 | branch: defaultBranchName
25 | })
26 | const defaultBranchCommitSha = defaultBranchData.commit.sha
27 | core.debug(`default branch commit sha: ${defaultBranchCommitSha}`)
28 |
29 | // get the latest commit on the default branch excluding the merge commit
30 | const {data: defaultBranchCommitsData} = await octokit.rest.repos.listCommits(
31 | {
32 | owner,
33 | repo,
34 | sha: defaultBranchName,
35 | per_page: 100
36 | }
37 | )
38 | var latestCommitSha
39 | for (const commit of defaultBranchCommitsData) {
40 | if (commit.parents.length === 1) {
41 | latestCommitSha = commit.sha
42 | break
43 | }
44 | }
45 | core.info(
46 | `latest commit on ${defaultBranchName} excluding the merge commit: ${latestCommitSha}`
47 | )
48 |
49 | // find the latest deployment with the payload type of branch-deploy
50 | const {data: deploymentsData} = await octokit.rest.repos.listDeployments({
51 | owner,
52 | repo,
53 | environment,
54 | sort: 'created_at',
55 | direction: 'desc',
56 | per_page: 100
57 | })
58 | // loop through all deployments and look for the latest deployment with the payload type of branch-deploy
59 | var latestDeploymentSha
60 | var createdAt
61 | var deploymentId
62 | for (const deployment of deploymentsData) {
63 | if (deployment.payload.type === 'branch-deploy') {
64 | latestDeploymentSha = deployment.sha
65 | createdAt = deployment.created_at
66 | deploymentId = deployment.id
67 | break
68 | }
69 | }
70 |
71 | core.info(`latest deployment sha: ${latestDeploymentSha}`)
72 | core.debug('latest deployment with payload type of "branch-deploy"')
73 | core.debug(`latest deployment sha: ${latestDeploymentSha}`)
74 | core.debug(`latest deployment created at: ${createdAt}`)
75 | core.debug(`latest deployment id: ${deploymentId}`)
76 |
77 | // use the compareCommitsWithBasehead API to check if the latest deployment sha is identical to the latest commit on the default branch
78 | const {data: compareData} =
79 | await octokit.rest.repos.compareCommitsWithBasehead({
80 | owner,
81 | repo,
82 | basehead: `${latestCommitSha}...${latestDeploymentSha}`
83 | })
84 |
85 | // if the latest deployment sha is identical to the latest commit on the default branch then return true
86 | const result = compareData.status === 'identical'
87 |
88 | if (result) {
89 | core.info('latest deployment sha is identical to the latest commit sha')
90 | core.info(
91 | 'identical commits will not be deployed again based on your configuration'
92 | )
93 | core.setOutput('continue', 'false')
94 | core.setOutput('environment', environment)
95 | } else {
96 | core.info(
97 | 'latest deployment is not identical to the latest commit on the default branch'
98 | )
99 | core.info('a new deployment will be created based on your configuration')
100 | core.setOutput('continue', 'true')
101 | core.setOutput('environment', environment)
102 | }
103 |
104 | return result
105 | }
106 |
--------------------------------------------------------------------------------
/src/functions/lock-metadata.js:
--------------------------------------------------------------------------------
1 | export const LOCK_METADATA = {
2 | lockInfoFlags: ['--info', '--i', '-i', '--details', '--d', '-d'],
3 | lockBranchSuffix: 'branch-deploy-lock',
4 | globalLockBranch: 'global-branch-deploy-lock',
5 | lockCommitMsg: 'lock [skip ci]',
6 | lockFile: 'lock.json'
7 | }
8 |
--------------------------------------------------------------------------------
/src/functions/post-deploy.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import {actionStatus} from './action-status'
3 | import {createDeploymentStatus} from './deployment'
4 | import {unlock} from './unlock'
5 | import {lock} from './lock'
6 | import dedent from 'dedent-js'
7 |
8 | // Helper function to help facilitate the process of completing a deployment
9 | // :param context: The GitHub Actions event context
10 | // :param octokit: The octokit client
11 | // :param comment_id: The comment_id which initially triggered the deployment Action
12 | // :param reaction_id: The reaction_id which was initially added to the comment that triggered the Action
13 | // :param status: The status of the deployment (String)
14 | // :param message: A custom string to add as the deployment status message (String)
15 | // :param ref: The ref (branch) which is being used for deployment (String)
16 | // :param noop: Indicates whether the deployment is a noop or not (String)
17 | // :param deployment_id: The id of the deployment (String)
18 | // :param environment: The environment of the deployment (String)
19 | // :param environment_url: The environment url of the deployment (String)
20 | // :param environment_url_in_comment: Indicates whether the environment url should be added to the comment (Boolean)
21 | // :returns: 'success' if the deployment was successful, 'success - noop' if a noop, throw error otherwise
22 | export async function postDeploy(
23 | context,
24 | octokit,
25 | comment_id,
26 | reaction_id,
27 | status,
28 | customMessage,
29 | ref,
30 | noop,
31 | deployment_id,
32 | environment,
33 | environment_url,
34 | environment_url_in_comment
35 | ) {
36 | // Check the inputs to ensure they are valid
37 | if (!comment_id || comment_id.length === 0) {
38 | throw new Error('no comment_id provided')
39 | } else if (!status || status.length === 0) {
40 | throw new Error('no status provided')
41 | } else if (!ref || ref.length === 0) {
42 | throw new Error('no ref provided')
43 | } else if (!noop || noop.length === 0) {
44 | throw new Error('no noop value provided')
45 | } else if (noop !== 'true') {
46 | if (!deployment_id || deployment_id.length === 0) {
47 | throw new Error('no deployment_id provided')
48 | }
49 | if (!environment || environment.length === 0) {
50 | throw new Error('no environment provided')
51 | }
52 | }
53 |
54 | // Check the deployment status
55 | var success
56 | if (status === 'success') {
57 | success = true
58 | } else {
59 | success = false
60 | }
61 |
62 | var deployTypeString = ' ' // a single space as a default
63 |
64 | // Set the mode and deploy type based on the deployment mode
65 | if (noop === 'true') {
66 | deployTypeString = ' **noop** '
67 | }
68 |
69 | // Dynamically set the message text depending if the deployment succeeded or failed
70 | var message
71 | var deployStatus
72 | if (status === 'success') {
73 | message = `**${context.actor}** successfully${deployTypeString}deployed branch \`${ref}\` to **${environment}**`
74 | deployStatus = '✅'
75 | } else if (status === 'failure') {
76 | message = `**${context.actor}** had a failure when${deployTypeString}deploying branch \`${ref}\` to **${environment}**`
77 | deployStatus = '❌'
78 | } else {
79 | message = `Warning:${deployTypeString}deployment status is unknown, please use caution`
80 | deployStatus = '⚠️'
81 | }
82 |
83 | // Conditionally format the message body
84 | var message_fmt
85 | if (customMessage && customMessage.length > 0) {
86 | const customMessageFmt = customMessage
87 | .replace(/\\n/g, '\n')
88 | .replace(/\\t/g, '\t')
89 | message_fmt = dedent(`
90 | ### Deployment Results ${deployStatus}
91 |
92 | ${message}
93 |
94 | Show Results
95 |
96 | ${customMessageFmt}
97 |
98 |
99 | `)
100 | } else {
101 | message_fmt = dedent(`
102 | ### Deployment Results ${deployStatus}
103 |
104 | ${message}
105 | `)
106 | }
107 |
108 | // Conditionally add the environment url to the message body
109 | // This message only gets added if the deployment was successful, and the noop mode is not enabled, and the environment url is not empty
110 | if (
111 | environment_url &&
112 | environment_url.length > 0 &&
113 | environment_url.trim() !== '' &&
114 | status === 'success' &&
115 | noop !== 'true' &&
116 | environment_url_in_comment === true
117 | ) {
118 | const environment_url_short = environment_url
119 | .replace('https://', '')
120 | .replace('http://', '')
121 | message_fmt += `\n\n> **Environment URL:** [${environment_url_short}](${environment_url})`
122 | }
123 |
124 | // Update the action status to indicate the result of the deployment as a comment
125 | await actionStatus(
126 | context,
127 | octokit,
128 | parseInt(reaction_id),
129 | message_fmt,
130 | success
131 | )
132 |
133 | // Update the deployment status of the branch-deploy
134 | var deploymentStatus
135 | if (success) {
136 | deploymentStatus = 'success'
137 | } else {
138 | deploymentStatus = 'failure'
139 | }
140 |
141 | // If the deployment mode is noop, return here
142 | if (noop === 'true') {
143 | // Obtain the lock data with detailsOnly set to true - ie we will not alter the lock
144 | const lockResponse = await lock(
145 | octokit,
146 | context,
147 | null, // ref
148 | null, // reaction_id
149 | false, // sticky
150 | environment, // environment
151 | true // detailsOnly set to true
152 | )
153 |
154 | // Obtain the lockData from the lock response
155 | const lockData = lockResponse.lockData
156 |
157 | // If the lock is sticky, we will not remove it
158 | if (lockData.sticky) {
159 | core.info('sticky lock detected, will not remove lock')
160 | } else if (lockData.sticky === false) {
161 | // Remove the lock - use silent mode
162 | await unlock(
163 | octokit,
164 | context,
165 | null, // reaction_id
166 | environment, // environment
167 | true // silent
168 | )
169 | }
170 |
171 | return 'success - noop'
172 | }
173 |
174 | // Update the final deployment status with either success or failure
175 | await createDeploymentStatus(
176 | octokit,
177 | context,
178 | ref,
179 | deploymentStatus,
180 | deployment_id,
181 | environment,
182 | environment_url // can be null
183 | )
184 |
185 | // Obtain the lock data with detailsOnly set to true - ie we will not alter the lock
186 | const lockResponse = await lock(
187 | octokit,
188 | context,
189 | null, // ref
190 | null, // reaction_id
191 | false, // sticky
192 | environment, // environment
193 | true, // detailsOnly set to true
194 | true // postDeployStep set to true - this means we will not exit early if a global lock exists
195 | )
196 |
197 | // Obtain the lockData from the lock response
198 | const lockData = lockResponse.lockData
199 |
200 | // If the lock is sticky, we will not remove it
201 | if (lockData.sticky) {
202 | core.info('sticky lock detected, will not remove lock')
203 | } else if (lockData.sticky === false) {
204 | // Remove the lock - use silent mode
205 | await unlock(
206 | octokit,
207 | context,
208 | null, // reaction_id
209 | environment, // environment
210 | true // silent
211 | )
212 | }
213 |
214 | // If the post deploy comment logic completes successfully, return
215 | return 'success'
216 | }
217 |
--------------------------------------------------------------------------------
/src/functions/post.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import {contextCheck} from './context-check'
3 | import {postDeploy} from './post-deploy'
4 | import * as github from '@actions/github'
5 | import {context} from '@actions/github'
6 |
7 | export async function post() {
8 | try {
9 | const ref = core.getState('ref')
10 | const comment_id = core.getState('comment_id')
11 | const reaction_id = core.getState('reaction_id')
12 | const noop = core.getState('noop')
13 | const deployment_id = core.getState('deployment_id')
14 | const environment = core.getState('environment')
15 | var environment_url = core.getState('environment_url')
16 | const token = core.getState('actionsToken')
17 | const bypass = core.getState('bypass')
18 | const status = core.getInput('status')
19 | const skip_completing = core.getInput('skip_completing')
20 | const environment_url_in_comment =
21 | core.getInput('environment_url_in_comment') === 'true'
22 | const deployMessage = process.env.DEPLOY_MESSAGE
23 |
24 | // If bypass is set, exit the workflow
25 | if (bypass === 'true') {
26 | core.warning('bypass set, exiting')
27 | return
28 | }
29 |
30 | // Check the context of the event to ensure it is valid, return if it is not
31 | if (!(await contextCheck(context))) {
32 | return
33 | }
34 |
35 | // Skip the process of completing a deployment, return
36 | if (skip_completing === 'true') {
37 | core.info('skip_completing set, exiting')
38 | return
39 | }
40 |
41 | // Create an octokit client
42 | const octokit = github.getOctokit(token)
43 |
44 | // Set the environment_url
45 | if (
46 | !environment_url ||
47 | environment_url.length === 0 ||
48 | environment_url === 'null' ||
49 | environment_url.trim() === ''
50 | ) {
51 | core.debug('environment_url not set, setting to null')
52 | environment_url = null
53 | }
54 |
55 | await postDeploy(
56 | context,
57 | octokit,
58 | comment_id,
59 | reaction_id,
60 | status,
61 | deployMessage,
62 | ref,
63 | noop,
64 | deployment_id,
65 | environment,
66 | environment_url,
67 | environment_url_in_comment
68 | )
69 |
70 | return
71 | } catch (error) {
72 | core.error(error.stack)
73 | core.setFailed(error.message)
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/functions/react-emote.js:
--------------------------------------------------------------------------------
1 | // Fixed presets of allowed emote types as defined by GitHub
2 | const presets = [
3 | '+1',
4 | '-1',
5 | 'laugh',
6 | 'confused',
7 | 'heart',
8 | 'hooray',
9 | 'rocket',
10 | 'eyes'
11 | ]
12 |
13 | // Helper function to add a reaction to an issue_comment
14 | // :param reaction: A string which determines the reaction to use (String)
15 | // :param context: The GitHub Actions event context
16 | // :param octokit: The octokit client
17 | // :returns: The reactRes object which contains the reaction ID among other things. Returns nil if no reaction was specified, or throws an error if it fails
18 | export async function reactEmote(reaction, context, octokit) {
19 | // Get the owner and repo from the context
20 | const {owner, repo} = context.repo
21 |
22 | // If the reaction is not specified, return
23 | if (!reaction || reaction.trim() === '') {
24 | return
25 | }
26 |
27 | // Find the reaction in the list of presets, otherwise throw an error
28 | const preset = presets.find(preset => preset === reaction.trim())
29 | if (!preset) {
30 | throw new Error(`Reaction "${reaction}" is not a valid preset`)
31 | }
32 |
33 | // Add the reaction to the issue_comment
34 | const reactRes = await octokit.rest.reactions.createForIssueComment({
35 | owner,
36 | repo,
37 | comment_id: context.payload.comment.id,
38 | content: preset
39 | })
40 |
41 | // Return the reactRes which contains the id for reference later
42 | return reactRes
43 | }
44 |
--------------------------------------------------------------------------------
/src/functions/string-to-array.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 |
3 | // Helper function to convert a String to an Array specifically in Actions
4 | // :param string: A comma seperated string to convert to an array
5 | // :return Array: The function returns an Array - can be empty
6 | export async function stringToArray(string) {
7 | try {
8 | // If the String is empty, return an empty Array
9 | if (string.trim() === '') {
10 | core.debug(
11 | 'in stringToArray(), an empty String was found so an empty Array was returned'
12 | )
13 | return []
14 | }
15 |
16 | // Split up the String on commas, trim each element, and return the Array
17 | const stringArray = string.split(',').map(target => target.trim())
18 | var results = []
19 |
20 | // filter out empty items
21 | for (const item of stringArray) {
22 | if (item === '') {
23 | continue
24 | }
25 | results.push(item)
26 | }
27 |
28 | return results
29 | } catch (error) {
30 | /* istanbul ignore next */
31 | core.error(`failed string for debugging purposes: ${string}`)
32 | /* istanbul ignore next */
33 | throw new Error(`could not convert String to Array - error: ${error}`)
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/functions/time-diff.js:
--------------------------------------------------------------------------------
1 | // Helper function to calculate the time difference between two dates
2 | // :param firstDate: ISO 8601 formatted date string
3 | // :param secondDate: ISO 8601 formatted date string
4 | // :returns: A string in the following format: `${days}d:${hours}h:${minutes}m:${seconds}s`
5 | export async function timeDiff(firstDate, secondDate) {
6 | const firstDateFmt = new Date(firstDate)
7 | const secondDateFmt = new Date(secondDate)
8 |
9 | var seconds = Math.floor((secondDateFmt - firstDateFmt) / 1000)
10 | var minutes = Math.floor(seconds / 60)
11 | var hours = Math.floor(minutes / 60)
12 | var days = Math.floor(hours / 24)
13 |
14 | hours = hours - days * 24
15 | minutes = minutes - days * 24 * 60 - hours * 60
16 | seconds = seconds - days * 24 * 60 * 60 - hours * 60 * 60 - minutes * 60
17 |
18 | return `${days}d:${hours}h:${minutes}m:${seconds}s`
19 | }
20 |
--------------------------------------------------------------------------------
/src/functions/trigger-check.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 |
3 | // A simple function that checks the body of the message against the trigger
4 | // :param prefixOnly: Input that determines if the whole comment should be checked for the trigger or just check if the trigger is the prefix of the message
5 | // :param body: The content body of the message being checked (String)
6 | // :param trigger: The "trigger" phrase which is searched for in the body of the message
7 | // :returns: true if a message activates the trigger, false otherwise
8 | export async function triggerCheck(prefixOnly, body, trigger) {
9 | // Set the output of the comment body for later use with other actions
10 | core.setOutput('comment_body', body)
11 |
12 | // If the trigger is not activated, set the output to false and return with false
13 | if ((prefixOnly && !body.startsWith(trigger)) || !body.includes(trigger)) {
14 | if (prefixOnly) {
15 | core.debug(`Trigger "${trigger}" not found as comment prefix`)
16 | } else {
17 | core.debug(`Trigger "${trigger}" not found in the comment body`)
18 | }
19 | return false
20 | }
21 |
22 | return true
23 | }
24 |
--------------------------------------------------------------------------------
/src/functions/unlock.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import {actionStatus} from './action-status'
3 | import dedent from 'dedent-js'
4 | import {LOCK_METADATA} from './lock-metadata'
5 |
6 | // Constants for the lock file
7 | const LOCK_BRANCH_SUFFIX = LOCK_METADATA.lockBranchSuffix
8 | const GLOBAL_LOCK_BRANCH = LOCK_METADATA.globalLockBranch
9 |
10 | // Helper function to find the environment to be unlocked (if any - otherwise, the default)
11 | // This function will also check if the global lock flag was provided
12 | // If the global lock flag was provided, the environment will be set to null
13 | // :param context: The GitHub Actions event context
14 | // :returns: An object - EX: {environment: 'staging', global: false}
15 | async function findEnvironment(context) {
16 | // Get the body of the comment
17 | var body = context.payload.comment.body.trim()
18 |
19 | // remove the --reason from the body if it exists
20 | if (body.includes('--reason')) {
21 | core.debug(
22 | `'--reason' found in unlock comment body: ${body} - attempting to remove for environment checks`
23 | )
24 | body = body.split('--reason')[0]
25 | core.debug(`comment body after '--reason' removal: ${body}`)
26 | }
27 |
28 | // Get the global lock flag from the Action input
29 | const globalFlag = core.getInput('global_lock_flag').trim()
30 |
31 | // Check if the global lock flag was provided
32 | if (body.includes(globalFlag) === true) {
33 | return {
34 | environment: null,
35 | global: true
36 | }
37 | }
38 |
39 | // remove the unlock command from the body
40 | const unlockTrigger = core.getInput('unlock_trigger').trim()
41 | body = body.replace(unlockTrigger, '').trim()
42 |
43 | // If the body is empty, return the default environment
44 | if (body === '') {
45 | return {
46 | environment: core.getInput('environment').trim(),
47 | global: false
48 | }
49 | } else {
50 | // If there is anything left in the body, return that as the environment
51 | return {
52 | environment: body,
53 | global: false
54 | }
55 | }
56 | }
57 |
58 | // Helper function for releasing a deployment lock
59 | // :param octokit: The octokit client
60 | // :param context: The GitHub Actions event context
61 | // :param reactionId: The ID of the reaction to add to the issue comment (only used if the lock is successfully released) (Integer)
62 | // :param environment: The environment to remove the lock from (String) - can be null and if so, the environment will be determined from the context
63 | // :param silent: A bool indicating whether to add a comment to the issue or not (Boolean)
64 | // :returns: true if the lock was successfully released, a string with some details if silent was used, false otherwise
65 | export async function unlock(
66 | octokit,
67 | context,
68 | reactionId,
69 | environment = null,
70 | silent = false
71 | ) {
72 | try {
73 | var branchName
74 | var global
75 | // Find the environment from the context if it was not passed in
76 | // If the environment is not being passed in, we can safely assuming that this function is not being called from a post-deploy Action and instead, it is being directly called from an IssueOps command
77 | if (environment === null) {
78 | const envObject = await findEnvironment(context)
79 | environment = envObject.environment
80 | global = envObject.global
81 | } else {
82 | // if the environment was passed in, we can assume it is not a global lock
83 | global = false
84 | }
85 |
86 | // construct the branch name and success message text
87 | var successText = ''
88 | if (global === true) {
89 | branchName = GLOBAL_LOCK_BRANCH
90 | successText = '`global`'
91 | } else {
92 | branchName = `${environment}-${LOCK_BRANCH_SUFFIX}`
93 | successText = `\`${environment}\``
94 | }
95 |
96 | // Delete the lock branch
97 | const result = await octokit.rest.git.deleteRef({
98 | ...context.repo,
99 | ref: `heads/${branchName}`
100 | })
101 |
102 | // If the lock was successfully released, return true
103 | if (result.status === 204) {
104 | core.info(`successfully removed lock`)
105 |
106 | // If silent, exit here
107 | if (silent) {
108 | core.debug('removing lock silently')
109 | return 'removed lock - silent'
110 | }
111 |
112 | // If a global lock was successfully released, set the output
113 | if (global === true) {
114 | core.setOutput('global_lock_released', 'true')
115 | }
116 |
117 | // Construct the message to add to the issue comment
118 | const comment = dedent(`
119 | ### 🔓 Deployment Lock Removed
120 |
121 | The ${successText} deployment lock has been successfully removed
122 | `)
123 |
124 | // Set the action status with the comment
125 | await actionStatus(context, octokit, reactionId, comment, true, true)
126 |
127 | // Return true
128 | return true
129 | } else {
130 | // If the lock was not successfully released, return false and log the HTTP code
131 | const comment = `failed to delete lock branch: ${branchName} - HTTP: ${result.status}`
132 | core.info(comment)
133 |
134 | // If silent, exit here
135 | if (silent) {
136 | core.debug('failed to delete lock (bad status code) - silent')
137 | return 'failed to delete lock (bad status code) - silent'
138 | }
139 |
140 | await actionStatus(context, octokit, reactionId, comment, false)
141 | return false
142 | }
143 | } catch (error) {
144 | // The the error caught was a 422 - Reference does not exist, this is OK - It means the lock branch does not exist
145 | if (error.status === 422 && error.message === 'Reference does not exist') {
146 | // If silent, exit here
147 | if (silent) {
148 | core.debug('no deployment lock currently set - silent')
149 | return 'no deployment lock currently set - silent'
150 | }
151 |
152 | // Format the comment
153 | var noLockMsg
154 | if (global === true) {
155 | noLockMsg = '🔓 There is currently no `global` deployment lock set'
156 | } else {
157 | noLockMsg = `🔓 There is currently no \`${environment}\` deployment lock set`
158 | }
159 |
160 | // Leave a comment letting the user know there is no lock to release
161 | await actionStatus(
162 | context,
163 | octokit,
164 | reactionId,
165 | noLockMsg,
166 | true, // success
167 | true // alt success reaction (ususally thumbs up)
168 | )
169 |
170 | // Return true since there is no lock to release
171 | return true
172 | }
173 |
174 | // If silent, exit here
175 | if (silent) {
176 | throw new Error(error)
177 | }
178 |
179 | // Update the PR with the error
180 | await actionStatus(context, octokit, reactionId, error.message, false)
181 |
182 | throw new Error(error)
183 | }
184 | }
185 |
--------------------------------------------------------------------------------
/src/functions/valid-permissions.js:
--------------------------------------------------------------------------------
1 | // Helper function to check if an actor has permissions to use this Action in a given repository
2 | // :param octokit: The octokit client
3 | // :param context: The GitHub Actions event context
4 | // :returns: An error string if the actor doesn't have permissions, otherwise true
5 | export async function validPermissions(octokit, context) {
6 | // Get the permissions of the user who made the comment
7 | const permissionRes = await octokit.rest.repos.getCollaboratorPermissionLevel(
8 | {
9 | ...context.repo,
10 | username: context.actor
11 | }
12 | )
13 |
14 | // Check permission API call status code
15 | if (permissionRes.status !== 200) {
16 | return `Permission check returns non-200 status: ${permissionRes.status}`
17 | }
18 |
19 | // Check to ensure the user has at least write permission on the repo
20 | const actorPermission = permissionRes.data.permission
21 | if (!['admin', 'write'].includes(actorPermission)) {
22 | return `👋 __${context.actor}__, seems as if you have not admin/write permissions in this repo, permissions: ${actorPermission}`
23 | }
24 |
25 | // Return true if the user has permissions
26 | return true
27 | }
28 |
--------------------------------------------------------------------------------
/src/main.js:
--------------------------------------------------------------------------------
1 | import * as core from '@actions/core'
2 | import {triggerCheck} from './functions/trigger-check'
3 | import {contextCheck} from './functions/context-check'
4 | import {reactEmote} from './functions/react-emote'
5 | import {environmentTargets} from './functions/environment-targets'
6 | import {actionStatus} from './functions/action-status'
7 | import {createDeploymentStatus} from './functions/deployment'
8 | import {prechecks} from './functions/prechecks'
9 | import {validPermissions} from './functions/valid-permissions'
10 | import {lock} from './functions/lock'
11 | import {unlock} from './functions/unlock'
12 | import {post} from './functions/post'
13 | import {timeDiff} from './functions/time-diff'
14 | import {identicalCommitCheck} from './functions/identical-commit-check'
15 | import {help} from './functions/help'
16 | import {LOCK_METADATA} from './functions/lock-metadata'
17 | import * as github from '@actions/github'
18 | import {context} from '@actions/github'
19 | import dedent from 'dedent-js'
20 |
21 | // :returns: 'success', 'success - noop', 'success - merge deploy mode', 'failure', 'safe-exit', or raises an error
22 | export async function run() {
23 | try {
24 | // Get the inputs for the branch-deploy Action
25 | const trigger = core.getInput('trigger')
26 | const reaction = core.getInput('reaction')
27 | const prefixOnly = core.getInput('prefix_only') === 'true'
28 | const token = core.getInput('github_token', {required: true})
29 | var environment = core.getInput('environment', {required: true})
30 | const stable_branch = core.getInput('stable_branch')
31 | const noop_trigger = core.getInput('noop_trigger')
32 | const lock_trigger = core.getInput('lock_trigger')
33 | const production_environment = core.getInput('production_environment')
34 | const environment_targets = core.getInput('environment_targets')
35 | const unlock_trigger = core.getInput('unlock_trigger')
36 | const help_trigger = core.getInput('help_trigger')
37 | const lock_info_alias = core.getInput('lock_info_alias')
38 | const global_lock_flag = core.getInput('global_lock_flag')
39 | const update_branch = core.getInput('update_branch')
40 | const required_contexts = core.getInput('required_contexts')
41 | const allowForks = core.getInput('allow_forks') === 'true'
42 | const skipCi = core.getInput('skip_ci')
43 | const skipReviews = core.getInput('skip_reviews')
44 | const mergeDeployMode = core.getInput('merge_deploy_mode') === 'true'
45 | const admins = core.getInput('admins')
46 | const environment_urls = core.getInput('environment_urls')
47 |
48 | // Create an octokit client
49 | const octokit = github.getOctokit(token)
50 |
51 | // Set the state so that the post run logic will trigger
52 | core.saveState('isPost', 'true')
53 | core.saveState('actionsToken', token)
54 |
55 | // If we are running in the merge deploy mode, run commit checks
56 | if (mergeDeployMode) {
57 | identicalCommitCheck(octokit, context, environment)
58 | // always bypass post run logic as they is an entirely alternate workflow from the core branch-deploy Action
59 | core.saveState('bypass', 'true')
60 | return 'success - merge deploy mode'
61 | }
62 |
63 | // Get the body of the IssueOps command
64 | const body = context.payload.comment.body.trim()
65 |
66 | // Check the context of the event to ensure it is valid, return if it is not
67 | if (!(await contextCheck(context))) {
68 | return 'safe-exit'
69 | }
70 |
71 | // Get variables from the event context
72 | const issue_number = context.payload.issue.number
73 | const {owner, repo} = context.repo
74 |
75 | // Check if the comment is a trigger and what type of trigger it is
76 | const isDeploy = await triggerCheck(prefixOnly, body, trigger)
77 | const isLock = await triggerCheck(prefixOnly, body, lock_trigger)
78 | const isUnlock = await triggerCheck(prefixOnly, body, unlock_trigger)
79 | const isHelp = await triggerCheck(prefixOnly, body, help_trigger)
80 | const isLockInfoAlias = await triggerCheck(
81 | prefixOnly,
82 | body,
83 | lock_info_alias
84 | )
85 |
86 | // Loop through all the triggers and check if there are multiple triggers
87 | // If multiple triggers are activated, exit (this is not allowed)
88 | var multipleTriggers = false
89 | for (const trigger of [
90 | isDeploy,
91 | isLock,
92 | isUnlock,
93 | isHelp,
94 | isLockInfoAlias
95 | ]) {
96 | if (trigger) {
97 | if (multipleTriggers) {
98 | core.saveState('bypass', 'true')
99 | core.setOutput('triggered', 'false')
100 | core.info(`body: ${body}`)
101 | core.setFailed(
102 | 'IssueOps message contains multiple commands, only one is allowed'
103 | )
104 | return 'failure'
105 | }
106 | multipleTriggers = true
107 | }
108 | }
109 |
110 | if (!isDeploy && !isLock && !isUnlock && !isHelp && !isLockInfoAlias) {
111 | // If the comment does not activate any triggers, exit
112 | core.saveState('bypass', 'true')
113 | core.setOutput('triggered', 'false')
114 | core.info('no trigger detected in comment - exiting')
115 | return 'safe-exit'
116 | } else if (isDeploy) {
117 | core.setOutput('type', 'deploy')
118 | } else if (isLock) {
119 | core.setOutput('type', 'lock')
120 | } else if (isUnlock) {
121 | core.setOutput('type', 'unlock')
122 | } else if (isHelp) {
123 | core.setOutput('type', 'help')
124 | } else if (isLockInfoAlias) {
125 | core.setOutput('type', 'lock-info-alias')
126 | }
127 |
128 | // If we made it this far, the action has been triggered in one manner or another
129 | core.setOutput('triggered', 'true')
130 |
131 | // Add the reaction to the issue_comment which triggered the Action
132 | const reactRes = await reactEmote(reaction, context, octokit)
133 | core.setOutput('comment_id', context.payload.comment.id)
134 | core.saveState('comment_id', context.payload.comment.id)
135 | core.setOutput('initial_reaction_id', reactRes.data.id)
136 | core.saveState('reaction_id', reactRes.data.id)
137 | core.setOutput('actor_handle', context.payload.comment.user.login)
138 |
139 | // If the command is a help request
140 | if (isHelp) {
141 | core.debug('help command detected')
142 | // Check to ensure the user has valid permissions
143 | const validPermissionsRes = await validPermissions(octokit, context)
144 | // If the user doesn't have valid permissions, return an error
145 | if (validPermissionsRes !== true) {
146 | await actionStatus(
147 | context,
148 | octokit,
149 | reactRes.data.id,
150 | validPermissionsRes
151 | )
152 | // Set the bypass state to true so that the post run logic will not run
153 | core.saveState('bypass', 'true')
154 | core.setFailed(validPermissionsRes)
155 | return 'failure'
156 | }
157 |
158 | // rollup all the inputs into a single object
159 | const inputs = {
160 | trigger: trigger,
161 | reaction: reaction,
162 | prefixOnly: prefixOnly,
163 | environment: environment,
164 | stable_branch: stable_branch,
165 | noop_trigger: noop_trigger,
166 | lock_trigger: lock_trigger,
167 | production_environment: production_environment,
168 | environment_targets: environment_targets,
169 | unlock_trigger: unlock_trigger,
170 | global_lock_flag: global_lock_flag,
171 | help_trigger: help_trigger,
172 | lock_info_alias: lock_info_alias,
173 | update_branch: update_branch,
174 | required_contexts: required_contexts,
175 | allowForks: allowForks,
176 | skipCi: skipCi,
177 | skipReviews: skipReviews,
178 | admins: admins
179 | }
180 |
181 | // Run the help command and exit
182 | await help(octokit, context, reactRes.data.id, inputs)
183 | core.saveState('bypass', 'true')
184 | return 'safe-exit'
185 | }
186 |
187 | // If the command is a lock/unlock request
188 | if (isLock || isUnlock || isLockInfoAlias) {
189 | // Check to ensure the user has valid permissions
190 | const validPermissionsRes = await validPermissions(octokit, context)
191 | // If the user doesn't have valid permissions, return an error
192 | if (validPermissionsRes !== true) {
193 | await actionStatus(
194 | context,
195 | octokit,
196 | reactRes.data.id,
197 | validPermissionsRes
198 | )
199 | // Set the bypass state to true so that the post run logic will not run
200 | core.saveState('bypass', 'true')
201 | core.setFailed(validPermissionsRes)
202 | return 'failure'
203 | }
204 |
205 | // Check if the environment being locked/unlocked is a valid environment
206 | const lockEnvTargetCheckObj = await environmentTargets(
207 | environment, // the default environment from the Actions inputs
208 | body, // the body of the comment
209 | lock_trigger,
210 | unlock_trigger,
211 | null, // the stable_branch is not used for lock/unlock
212 | context, // the context object
213 | octokit, // the octokit object
214 | reactRes.data.id,
215 | true // lockChecks set to true as this is for lock/unlock requests
216 | )
217 |
218 | // extract the environment target from the lockEnvTargetCheckObj
219 | const lockEnvTargetCheck = lockEnvTargetCheckObj.environment
220 |
221 | // If the environment targets are not valid, then exit
222 | if (!lockEnvTargetCheck) {
223 | core.debug('No valid environment targets found for lock/unlock request')
224 | return 'safe-exit'
225 | }
226 |
227 | // If it is a lock or lock info releated request
228 | if (isLock || isLockInfoAlias) {
229 | // If the lock request is only for details
230 | if (
231 | LOCK_METADATA.lockInfoFlags.some(
232 | substring => body.includes(substring) === true
233 | ) ||
234 | isLockInfoAlias === true
235 | ) {
236 | // Get the lock details from the lock file
237 | const lockResponse = await lock(
238 | octokit,
239 | context,
240 | null, // ref
241 | reactRes.data.id,
242 | null, // sticky
243 | null, // environment (we will find this in the lock function)
244 | true // details only flag
245 | )
246 | // extract values from the lock response
247 | const lockData = lockResponse.lockData
248 | const lockStatus = lockResponse.status
249 |
250 | // If a lock was found
251 | if (lockStatus !== null) {
252 | // Find the total time since the lock was created
253 | const totalTime = await timeDiff(
254 | lockData.created_at,
255 | new Date().toISOString()
256 | )
257 |
258 | // special comment for global deploy locks
259 | let globalMsg = ''
260 | let environmentMsg = `- __Environment__: \`${lockData.environment}\``
261 | let lockBranchName = `${lockData.environment}-${LOCK_METADATA.lockBranchSuffix}`
262 | if (lockData.global === true) {
263 | globalMsg = dedent(`
264 |
265 | This is a **global** deploy lock - All environments are currently locked
266 |
267 | `)
268 | environmentMsg = dedent(`
269 | - __Environments__: \`all\`
270 | - __Global__: \`true\`
271 | `)
272 | core.info('there is a global deployment lock on this repository')
273 | lockBranchName = LOCK_METADATA.globalLockBranch
274 | }
275 |
276 | // Format the lock details message
277 | const lockMessage = dedent(`
278 | ### Lock Details 🔒
279 |
280 | The deployment lock is currently claimed by __${lockData.created_by}__${globalMsg}
281 |
282 | - __Reason__: \`${lockData.reason}\`
283 | - __Branch__: \`${lockData.branch}\`
284 | - __Created At__: \`${lockData.created_at}\`
285 | - __Created By__: \`${lockData.created_by}\`
286 | - __Sticky__: \`${lockData.sticky}\`
287 | ${environmentMsg}
288 | - __Comment Link__: [click here](${lockData.link})
289 | - __Lock Link__: [click here](${process.env.GITHUB_SERVER_URL}/${owner}/${repo}/blob/${lockBranchName}/${LOCK_METADATA.lockFile})
290 |
291 | The current lock has been active for \`${totalTime}\`
292 |
293 | > If you need to release the lock, please comment \`${lockData.unlock_command}\`
294 | `)
295 |
296 | // Update the issue comment with the lock details
297 | await actionStatus(
298 | context,
299 | octokit,
300 | reactRes.data.id,
301 | lockMessage,
302 | true,
303 | true
304 | )
305 | core.info(
306 | `the deployment lock is currently claimed by __${lockData.created_by}__`
307 | )
308 | } else if (lockStatus === null) {
309 | // format the lock details message
310 | var lockCommand
311 | var lockTarget
312 | if (lockResponse.global) {
313 | lockTarget = 'global'
314 | lockCommand = `${lock_trigger} ${lockResponse.globalFlag}`
315 | } else {
316 | lockTarget = lockResponse.environment
317 | lockCommand = `${lock_trigger} ${lockTarget}`
318 | }
319 |
320 | const lockMessage = dedent(`
321 | ### Lock Details 🔒
322 |
323 | No active \`${lockTarget}\` deployment locks found for the \`${owner}/${repo}\` repository
324 |
325 | > If you need to create a \`${lockTarget}\` lock, please comment \`${lockCommand}\`
326 | `)
327 |
328 | await actionStatus(
329 | context,
330 | octokit,
331 | reactRes.data.id,
332 | lockMessage,
333 | true,
334 | true
335 | )
336 | core.info('no active deployment locks found')
337 | }
338 |
339 | // Exit the action since we are done after obtaining only the lock details with --details
340 | core.saveState('bypass', 'true')
341 | return 'safe-exit'
342 | }
343 |
344 | // If the request is a lock request, attempt to claim the lock with a sticky request with the logic below
345 |
346 | // Get the ref to use with the lock request
347 | const pr = await octokit.rest.pulls.get({
348 | ...context.repo,
349 | pull_number: context.issue.number
350 | })
351 |
352 | // Send the lock request
353 | await lock(
354 | octokit,
355 | context,
356 | pr.data.head.ref,
357 | reactRes.data.id,
358 | true, // sticky
359 | null, // environment (we will find this in the lock function)
360 | false // details only flag
361 | )
362 | core.saveState('bypass', 'true')
363 | return 'safe-exit'
364 | }
365 |
366 | // If the request is an unlock request, attempt to release the lock
367 | if (isUnlock) {
368 | await unlock(octokit, context, reactRes.data.id)
369 | core.saveState('bypass', 'true')
370 | return 'safe-exit'
371 | }
372 | }
373 |
374 | // Check if the default environment is being overwritten by an explicit environment
375 | const environmentObj = await environmentTargets(
376 | environment, // environment
377 | body, // comment body
378 | trigger, // trigger
379 | noop_trigger, // noop trigger
380 | stable_branch, // ref
381 | context, // context object
382 | octokit, // octokit object
383 | reactRes.data.id, // reaction id
384 | false, // lockChecks set to false as this is for a deployment
385 | environment_urls // environment_urls action input
386 | )
387 |
388 | // deconstruct the environment object to get the environment
389 | environment = environmentObj.environment
390 |
391 | // If the environment targets are not valid, then exit
392 | if (!environment) {
393 | core.debug('No valid environment targets found')
394 | return 'safe-exit'
395 | }
396 |
397 | core.info(`environment: ${environment}`)
398 | core.saveState('environment', environment)
399 | core.setOutput('environment', environment)
400 |
401 | // Execute prechecks to ensure the Action can proceed
402 | const precheckResults = await prechecks(
403 | body,
404 | trigger,
405 | noop_trigger,
406 | update_branch,
407 | stable_branch,
408 | issue_number,
409 | allowForks,
410 | skipCi,
411 | skipReviews,
412 | environment,
413 | context,
414 | octokit
415 | )
416 | core.setOutput('ref', precheckResults.ref)
417 | core.saveState('ref', precheckResults.ref)
418 | core.setOutput('sha', precheckResults.sha)
419 |
420 | // If the prechecks failed, run the actionFailed function and return
421 | if (!precheckResults.status) {
422 | await actionStatus(
423 | context,
424 | octokit,
425 | reactRes.data.id,
426 | precheckResults.message
427 | )
428 | // Set the bypass state to true so that the post run logic will not run
429 | core.saveState('bypass', 'true')
430 | core.setFailed(precheckResults.message)
431 | return 'failure'
432 | }
433 |
434 | // Aquire the branch-deploy lock for non-sticky requests
435 | const lockResponse = await lock(
436 | octokit,
437 | context,
438 | precheckResults.ref,
439 | reactRes.data.id,
440 | false, // sticky
441 | environment
442 | )
443 |
444 | // If the lock request fails, exit the Action
445 | if (lockResponse.status === false) {
446 | return 'safe-exit'
447 | }
448 |
449 | // Add a comment to the PR letting the user know that a deployment has been started
450 | // Format the success message
451 | var deploymentType
452 | if (precheckResults.noopMode) {
453 | deploymentType = 'noop'
454 | } else {
455 | deploymentType = 'branch'
456 | }
457 | const log_url = `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.GITHUB_RUN_ID}`
458 | const commentBody = dedent(`
459 | ### Deployment Triggered 🚀
460 |
461 | __${context.actor}__, started a __${deploymentType}__ deployment to __${environment}__
462 |
463 | You can watch the progress [here](${log_url}) 🔗
464 |
465 | > __Branch__: \`${precheckResults.ref}\`
466 | `)
467 |
468 | // Make a comment on the PR
469 | await octokit.rest.issues.createComment({
470 | ...context.repo,
471 | issue_number: context.issue.number,
472 | body: commentBody
473 | })
474 |
475 | // Set outputs for noopMode
476 | var noop
477 | if (precheckResults.noopMode) {
478 | noop = 'true'
479 | core.setOutput('noop', noop)
480 | core.setOutput('continue', 'true')
481 | core.saveState('noop', noop)
482 | core.info('noop mode detected')
483 | // If noop mode is enabled, return
484 | return 'success - noop'
485 | } else {
486 | noop = 'false'
487 | core.setOutput('noop', noop)
488 | core.saveState('noop', noop)
489 | }
490 |
491 | // Get required_contexts for the deployment
492 | var requiredContexts = []
493 | if (
494 | required_contexts &&
495 | required_contexts !== '' &&
496 | required_contexts !== 'false'
497 | ) {
498 | requiredContexts = required_contexts.split(',').map(function (item) {
499 | return item.trim()
500 | })
501 | }
502 |
503 | // Check if the environment is a production_environment
504 | var productionEnvironment = false
505 | if (environment === production_environment.trim()) {
506 | productionEnvironment = true
507 | }
508 | core.debug(`production_environment: ${productionEnvironment}`)
509 |
510 | // if update_branch is set to 'disabled', then set auto_merge to false, otherwise set it to true
511 | const auto_merge = update_branch === 'disabled' ? false : true
512 |
513 | // Create a new deployment
514 | const {data: createDeploy} = await octokit.rest.repos.createDeployment({
515 | owner: owner,
516 | repo: repo,
517 | ref: precheckResults.ref,
518 | auto_merge: auto_merge,
519 | required_contexts: requiredContexts,
520 | environment: environment,
521 | // description: "",
522 | // :description note: Short description of the deployment.
523 | production_environment: productionEnvironment,
524 | // :production_environment note: specifies if the given environment is one that end-users directly interact with. Default: true when environment is production and false otherwise.
525 | payload: {
526 | type: 'branch-deploy'
527 | }
528 | })
529 | core.setOutput('deployment_id', createDeploy.id)
530 | core.saveState('deployment_id', createDeploy.id)
531 |
532 | // If a merge to the base branch is required, let the user know and exit
533 | if (
534 | typeof createDeploy.id === 'undefined' &&
535 | createDeploy.message.includes('Auto-merged')
536 | ) {
537 | const mergeMessage = dedent(`
538 | ### ⚠️ Deployment Warning
539 |
540 | - Message: ${createDeploy.message}
541 | - Note: If you have required CI checks, you may need to manually push a commit to re-run them
542 |
543 | > Deployment will not continue. Please try again once this branch is up-to-date with the base branch
544 | `)
545 | await actionStatus(context, octokit, reactRes.data.id, mergeMessage)
546 | core.warning(mergeMessage)
547 | // Enable bypass for the post deploy step since the deployment is not complete
548 | core.saveState('bypass', 'true')
549 | return 'safe-exit'
550 | }
551 |
552 | // Set the deployment status to in_progress
553 | await createDeploymentStatus(
554 | octokit,
555 | context,
556 | precheckResults.ref,
557 | 'in_progress',
558 | createDeploy.id,
559 | environment,
560 | environmentObj.environmentUrl // environment_url (can be null)
561 | )
562 |
563 | core.setOutput('continue', 'true')
564 | return 'success'
565 | } catch (error) {
566 | core.saveState('bypass', 'true')
567 | core.error(error.stack)
568 | core.setFailed(error.message)
569 | }
570 | }
571 |
572 | /* istanbul ignore next */
573 | if (core.getState('isPost') === 'true') {
574 | post()
575 | } else {
576 | if (process.env.CI === 'true') {
577 | run()
578 | }
579 | }
580 |
--------------------------------------------------------------------------------