├── .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 | Coverage: 100%Coverage100% -------------------------------------------------------------------------------- /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 | ![lock](https://user-images.githubusercontent.com/23362539/224514302-f26c9142-6b80-4007-a7b4-1d4236f472f3.png) 65 | 66 | Unlock Example: 67 | 68 | ![unlock](https://user-images.githubusercontent.com/23362539/224514330-c9951a9e-a571-4f16-bdd5-2f636185ad5a.png) 69 | 70 | Locking a specific environment (not just the default one): 71 | 72 | ![lock-development](https://user-images.githubusercontent.com/23362539/224514369-51956c50-1ea5-4287-a8f5-772daf9931a1.png) 73 | 74 | Obtaining the lock details for development: 75 | 76 | ![development-lock-details](https://user-images.githubusercontent.com/23362539/224514399-63fdbab1-6d49-4d02-8ac7-935fcb10cde5.png) 77 | 78 | Remove the lock for development: 79 | 80 | ![remove-development-lock](https://user-images.githubusercontent.com/23362539/224514423-81d31af4-9243-42dc-8052-8c3436b28760.png) 81 | 82 | Creating a global deploy lock: 83 | 84 | ![global-deploy-lock](https://user-images.githubusercontent.com/23362539/224514460-79dcd943-0b23-42b7-928f-a25b036a0c45.png) 85 | 86 | Removing the global deploy lock: 87 | 88 | ![remove-global-deploy-lock](https://user-images.githubusercontent.com/23362539/224514485-e60605fd-0918-466e-9aab-7597fa32e7d9.png) 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 | --------------------------------------------------------------------------------