├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── check-linked-issues.yml │ ├── ci.yml │ ├── notify-release.yml │ └── release.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmrc ├── .nvmrc ├── LICENSE ├── README.md ├── action.yml ├── dist └── index.js ├── eslint.config.js ├── package-lock.json ├── package.json ├── src ├── action.js ├── getDependabotDetails.js ├── github-client.js ├── index.js ├── log.js ├── mapUpdateType.js ├── util.js └── verifyCommitSignatures.js └── test ├── action.test.js ├── github-client.test.js ├── log.test.js ├── mapUpdateType.test.js ├── util.test.js └── verifyCommitSignatures.test.js /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behavior to automatically convert line endings 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | open-pull-requests-limit: 10 8 | 9 | - package-ecosystem: "npm" 10 | directory: "/" 11 | schedule: 12 | interval: "monthly" 13 | open-pull-requests-limit: 10 14 | -------------------------------------------------------------------------------- /.github/workflows/check-linked-issues.yml: -------------------------------------------------------------------------------- 1 | name: Check linked issues 2 | 3 | on: 4 | pull_request_target: 5 | types: [opened, edited, reopened, synchronize] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | check_pull_requests: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | issues: read 15 | pull-requests: write 16 | name: Check linked issues 17 | steps: 18 | - uses: nearform-actions/github-action-check-linked-issues@7140e2e01aa0c18b8ac61bddf5e89abde1ca760e # v1.8.3 19 | id: check-linked-issues 20 | with: 21 | github-token: ${{ github.token }} 22 | exclude-branches: "release/**, dependabot/**" 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v5 18 | with: 19 | persist-credentials: false 20 | - uses: actions/setup-node@v5 21 | with: 22 | check-latest: true 23 | node-version-file: '.nvmrc' 24 | - run: npm ci --ignore-scripts 25 | - run: npm run lint 26 | - run: npm run test 27 | 28 | automerge: 29 | needs: build 30 | runs-on: ubuntu-latest 31 | permissions: 32 | pull-requests: write 33 | contents: write 34 | steps: 35 | - uses: fastify/github-action-merge-dependabot@main 36 | with: 37 | github-token: ${{ github.token }} 38 | target: minor 39 | -------------------------------------------------------------------------------- /.github/workflows/notify-release.yml: -------------------------------------------------------------------------------- 1 | name: notify-release 2 | on: 3 | workflow_dispatch: 4 | release: 5 | types: [published] 6 | schedule: 7 | - cron: '30 8 * * *' 8 | permissions: 9 | contents: read 10 | jobs: 11 | setup: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | issues: write 15 | contents: read 16 | steps: 17 | - name: Notify release 18 | uses: nearform-actions/github-action-notify-release@1a1c03a2b8c9a41a7a88f9a8de05b2b6582f0966 # v1.12.2 19 | with: 20 | github-token: ${{ github.token }} 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | semver: 7 | description: "The semver to use" 8 | required: true 9 | default: "patch" 10 | type: choice 11 | options: 12 | - patch 13 | - minor 14 | - major 15 | pull_request: 16 | types: [closed] 17 | 18 | permissions: 19 | contents: read 20 | 21 | jobs: 22 | release: 23 | runs-on: ubuntu-latest 24 | permissions: 25 | contents: write 26 | issues: write 27 | pull-requests: write 28 | steps: 29 | - name: Setting action build runtime 30 | uses: actions/setup-node@v5 31 | with: 32 | check-latest: true 33 | node-version: 20 34 | - uses: nearform-actions/optic-release-automation-action@a2785548e8bdafab7e628c2317b604278e460434 # v4.12.2 35 | with: 36 | github-token: ${{ github.token }} 37 | semver: ${{ github.event.inputs.semver }} 38 | sync-semver-tags: true 39 | build-command: | 40 | npm ci 41 | npm run build 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | node_modules/ 3 | .idea 4 | .tap/ 5 | coverage -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run build && git add dist 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-scripts=true 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016-present The Fastify team 4 | 5 | The Fastify team members are listed at https://github.com/fastify/fastify#team. 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Github Action Merge Dependabot 2 | 3 | This action automatically approves and merges dependabot PRs. 4 | 5 | ## Usage 6 | 7 | Configure this action in your workflows providing the inputs described below. 8 | Note that this action requires a GitHub token with additional permissions. You must use the [`permissions`](https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#permissions) tag to specify the required rules or configure your [GitHub account](https://github.blog/changelog/2021-04-20-github-actions-control-permissions-for-github_token/). 9 | 10 | The permissions required are: 11 | 12 | - [`pull-requests`](https://docs.github.com/en/rest/overview/permissions-required-for-github-apps?apiVersion=2022-11-28#pull-requests): it is needed to approve PRs. 13 | - [`contents`](https://docs.github.com/en/rest/overview/permissions-required-for-github-apps?apiVersion=2022-11-28#contents): it is necessary to merge the pull request. You don't need it if you set `approve-only: true`, see [Approving without merging](#approving-without-merging) example below. 14 | 15 | If some of the required permissions are missing, the action will fail with the error message: 16 | 17 | ``` 18 | Error: Resource not accessible by integration 19 | ``` 20 | 21 | ## Inputs 22 | 23 | | input | required | default | description | 24 | | -------------------------- | -------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 25 | | `github-token` | No | `${{github.token}}` | A GitHub token. | 26 | | `exclude` | No | | A comma or semicolon separated value of packages that you don't want to auto-merge and would like to manually review to decide whether to upgrade or not. | 27 | | `approve-only` | No | `false` | If `true`, the PR is only approved but not merged. | 28 | | `merge-method` | No | `squash` | The merge method you would like to use (squash, merge, rebase). | 29 | | `merge-comment` | No | `''` | An arbitrary message that you'd like to comment on the PR after it gets auto-merged. This is only useful when you're receiving too much of noise in email and would like to filter mails for PRs that got automatically merged. | 30 | | `use-github-auto-merge` | No | `false` | If `true`, the PR is marked as auto-merge and will be merged by GitHub when status checks are satisfied.

_NOTE_: This feature only works when all of the following conditions are met.
- The repository enables auto-merge.
- The pull request base must have a branch protection rule.
- The pull request's status checks are not yet satisfied.

Refer to [the official document](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/automatically-merging-a-pull-request) about GitHub auto-merge. | 31 | | `target` | No | `any` | A flag to only auto-merge updates based on Semantic Versioning.
Possible options are: `major, premajor, minor, preminor, patch, prepatch, prerelease, any`.

The value of this flag allows for updates for all the matching versions **and lower** with the respect for priority. This means, for example, if the `target` is set to `major` and the update is made to `minor` version the auto-merge will be triggered.

For more details on how semantic version difference is calculated please see [semver](https://www.npmjs.com/package/semver) package.

If you set a value other than `any`, PRs that are not semantic version compliant are skipped. An example of a non-semantic version is a commit hash when using git submodules. | 32 | | `target-development` | No | | Same as `target` but specifies semver for `development` dependencies only. If present, then it overrides the value in `target` for `development` dependencies. | 33 | | `target-production` | No | | Same as `target` but specifies semver for `production` dependencies only. If present, then it overrides the value in `target` for `production` dependencies. | 34 | | `target-indirect` | No | | Same as `target` but specifies semver for indirect dependency updates only. If present, then it overrides the value in `target` for indirect dependency updates. | 35 | | `pr-number` | No | | A pull request number, only required if triggered from a workflow_dispatch event. Typically this would be triggered by a script running in a separate CI provider. See [Trigger action from workflow_dispatch event](#trigger-action-from-workflow_dispatch-event) example. | 36 | | `skip-commit-verification` | No | `false` | If `true`, then the action will not expect the commits to have a verification signature. It is required to set this to `true` in GitHub Enterprise Server. | 37 | | `skip-verification` | No | `false` | If true, the action will not validate the user or the commit verification status | 38 | 39 | ## Output 40 | 41 | | outputs | Description | 42 | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 43 | | merge_status | The result status of the merge. It can be one of the following: `approved`, `merged`, `auto_merge`, `merge_failed`, `skipped:commit_verification_failed`, `skipped:not_a_dependabot_pr`, `skipped:cannot_update_major`, `skipped:bump_higher_than_target`, `skipped:packaged_excluded` | 44 | 45 | ## Examples 46 | 47 | ### Basic example 48 | 49 | ```yml 50 | name: CI 51 | on: 52 | push: 53 | branches: 54 | - main 55 | pull_request: 56 | 57 | jobs: 58 | build: 59 | runs-on: ubuntu-latest 60 | steps: 61 | # ... 62 | 63 | automerge: 64 | needs: build 65 | runs-on: ubuntu-latest 66 | 67 | permissions: 68 | pull-requests: write 69 | contents: write 70 | 71 | steps: 72 | - uses: fastify/github-action-merge-dependabot@v3 73 | ``` 74 | 75 | ### Excluding packages 76 | 77 | ```yml 78 | permissions: 79 | pull-requests: write 80 | contents: write 81 | 82 | steps: 83 | - uses: fastify/github-action-merge-dependabot@v3 84 | with: 85 | exclude: 'react,fastify' 86 | ``` 87 | 88 | ### Approving without merging 89 | 90 | ```yml 91 | permissions: 92 | pull-requests: write 93 | steps: 94 | - uses: fastify/github-action-merge-dependabot@v3 95 | with: 96 | approve-only: true 97 | ``` 98 | 99 | ### Specifying target versions 100 | 101 | #### Specifying target for all packages 102 | 103 | ```yml 104 | steps: 105 | - uses: fastify/github-action-merge-dependabot@v3 106 | with: 107 | target: 'minor' 108 | ``` 109 | 110 | #### Specifying target for development and production packages separately 111 | 112 | ```yml 113 | steps: 114 | - uses: fastify/github-action-merge-dependabot@v3 115 | with: 116 | target-development: 'major' 117 | target-production: 'minor' 118 | ``` 119 | 120 | ### Trigger action from workflow_dispatch event 121 | 122 | If you need to trigger this action manually, you can use the [`workflow_dispatch`](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#workflow_dispatch) event. A use case might be that your CI runs on a seperate provider, so you would like to run this action as a result of a successful CI run. 123 | 124 | When using the `workflow_dispatch` approach, you will need to send the PR number as part of the input for this action: 125 | 126 | ```yml 127 | name: automerge 128 | 129 | on: 130 | workflow_dispatch: 131 | inputs: 132 | pr-number: 133 | required: true 134 | 135 | jobs: 136 | automerge: 137 | runs-on: ubuntu-latest 138 | permissions: 139 | pull-requests: write 140 | contents: write 141 | steps: 142 | - uses: fastify/github-action-merge-dependabot@v3 143 | with: 144 | pr-number: ${{ github.event.inputs.pr-number }} 145 | ``` 146 | 147 | You can initiate a call to trigger this event via [API](https://docs.github.com/en/rest/reference/actions/#create-a-workflow-dispatch-event): 148 | 149 | ```bash 150 | # Note: replace dynamic values with your relevant data 151 | curl -X POST \ 152 | -H "Accept: application/vnd.github.v3+json" \ 153 | -H "Authorization: token {token}" \ 154 | https://api.github.com/repos/{owner}/{reponame}/actions/workflows/{workflow}/dispatches \ 155 | -d '{"ref":"{ref}", "inputs":{ "pr-number": "{number}"}}' 156 | ``` 157 | 158 | ## How to upgrade from `2.x` to new `3.x` 159 | 160 | - Update the action version. 161 | - Add the new `permissions` configuration into your workflow or, instead, you can set the permissions rules on [the repository](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#setting-the-permissions-of-the-github_token-for-your-repository) or on [the organization](https://docs.github.com/en/enterprise-server@3.3/admin/policies/enforcing-policies-for-your-enterprise/enforcing-policies-for-github-actions-in-your-enterprise#enforcing-a-policy-for-workflow-permissions-in-your-enterprise). 162 | - Uninstall the [dependabot-merge-action](https://github.com/apps/dependabot-merge-action) GitHub App from your repos/orgs. 163 | - If you have customized the `api-url` you can: 164 | - Remove the `api-url` option from your workflow. 165 | - Turn off the [`dependabot-merge-action-app`](https://github.com/fastify/dependabot-merge-action-app/) application. 166 | 167 | Migration example: 168 | 169 | ```diff 170 | jobs: 171 | build: 172 | runs-on: ubuntu-latest 173 | steps: 174 | # ... 175 | 176 | automerge: 177 | needs: build 178 | runs-on: ubuntu-latest 179 | + permissions: 180 | + pull-requests: write 181 | + contents: write 182 | steps: 183 | - - uses: fastify/github-action-merge-dependabot@v2.1.1 184 | + - uses: fastify/github-action-merge-dependabot@v3 185 | ``` 186 | 187 | ## Notes 188 | 189 | - A GitHub token is automatically provided by Github Actions, which can be accessed using `github.token`. If you want to provide a token that's not the default one you can used the `github-token` input. 190 | - Make sure to use `needs: ` to delay the auto-merging until CI checks (test/build) are passed. 191 | - If you want to use GitHub's [auto-merge](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/automatically-merging-a-pull-request) feature but still use this action to approve Pull Requests without merging, use `approve-only: true`. 192 | 193 | ## Acknowledgements 194 | 195 | This project is kindly sponsored by [NearForm](https://nearform.com) 196 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Github Action Merge Dependabot' 2 | description: 'Automatically approve and merge dependabot PRs' 3 | 4 | inputs: 5 | github-token: 6 | description: 'A GitHub token' 7 | required: false 8 | default: ${{ github.token }} 9 | exclude: 10 | description: 'Packages that you want to manually review before upgrading' 11 | required: false 12 | approve-only: 13 | description: 'If true, the PR is only approved but not merged' 14 | required: false 15 | default: false 16 | merge-method: 17 | description: 'The merge method you would like to use (squash, merge, rebase)' 18 | required: false 19 | default: 'squash' 20 | merge-comment: 21 | description: "An arbitrary message that you'd like to comment on the PR after it gets auto-merged" 22 | required: false 23 | default: '' 24 | use-github-auto-merge: 25 | description: 'If true, the PR is only marked as auto-merge enabled' 26 | required: false 27 | default: false 28 | target: 29 | description: 'Auto-merge on updates based on Semantic Versioning' 30 | required: false 31 | default: 'any' 32 | target-development: 33 | description: 'Auto-merge on updates based on Semantic Versioning for development dependencies' 34 | required: false 35 | target-production: 36 | description: 'Auto-merge on updates based on Semantic Versioning for production dependencies' 37 | required: false 38 | target-indirect: 39 | description: 'Auto-merge on updates based on Semantic Versioning for indirect updates' 40 | required: false 41 | pr-number: 42 | description: 'A pull request number, only required if triggered from a workflow_dispatch event' 43 | required: false 44 | skip-commit-verification: 45 | description: 'If true, then the action will not expect the commits to have a verification signature. It is required to set this to true in GitHub Enterprise Server' 46 | required: false 47 | default: false 48 | skip-verification: 49 | type: boolean 50 | description: 'If true, the action will not validate the user or the commit verification status' 51 | default: false 52 | 53 | outputs: 54 | merge_status: 55 | description: 'Merge status' 56 | value: ${{ steps.approver.outputs.merge_status }} 57 | 58 | runs: 59 | using: 'composite' 60 | steps: 61 | - name: Fetch metadata 62 | id: dependabot-metadata 63 | uses: dependabot/fetch-metadata@v2 64 | if: github.event_name == 'pull_request' && (github.actor == 'dependabot[bot]' || inputs.skip-verification == 'true') 65 | with: 66 | skip-commit-verification: ${{ inputs.skip-commit-verification }} 67 | skip-verification: ${{ inputs.skip-verification }} 68 | - name: Merge/approve PR 69 | uses: actions/github-script@v8 70 | id: approver 71 | env: 72 | ACTION_PATH: ${{ github.action_path }} 73 | UPDATE_TYPE: ${{ steps.dependabot-metadata.outputs.update-type }} 74 | DEPENDENCY_TYPE: ${{ steps.dependabot-metadata.outputs.dependency-type }} 75 | DEPENDENCY_NAMES: ${{ steps.dependabot-metadata.outputs.dependency-names }} 76 | with: 77 | github-token: ${{ inputs.github-token }} 78 | script: | 79 | const { ACTION_PATH, UPDATE_TYPE, DEPENDENCY_TYPE, DEPENDENCY_NAMES } = process.env 80 | const script = require(ACTION_PATH + '/dist/index.js') 81 | await script({ 82 | github, 83 | context, 84 | inputs: ${{ toJSON(inputs) }}, 85 | dependabotMetadata: { 86 | updateType: UPDATE_TYPE, 87 | dependencyType: DEPENDENCY_TYPE, 88 | dependencyNames: DEPENDENCY_NAMES, 89 | } 90 | }) 91 | 92 | branding: 93 | icon: 'git-pull-request' 94 | color: 'gray-dark' 95 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = require('neostandard')({ 4 | semi: false, 5 | ignores: ['dist', 'node_modules'], 6 | env: ['es2015', 'node'], 7 | }) 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-action-merge-dependabot", 3 | "version": "3.11.2", 4 | "description": "A GitHub action to automatically merge and approve Dependabot pull requests", 5 | "main": "src/index.js", 6 | "type": "commonjs", 7 | "scripts": { 8 | "build": "ncc build src/index.js", 9 | "lint": "eslint .", 10 | "lint:fix": "eslint . --fix", 11 | "test": "c8 --100 node --test", 12 | "prepare": "husky install" 13 | }, 14 | "author": { 15 | "name": "Salman Mitha", 16 | "email": "SalmanMitha@gmail.com" 17 | }, 18 | "contributors": [ 19 | "Simone Busoli " 20 | ], 21 | "license": "MIT", 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/fastify/github-action-merge-dependabot.git" 25 | }, 26 | "bugs": { 27 | "url": "https://github.com/fastify/github-action-merge-dependabot/issues" 28 | }, 29 | "homepage": "https://github.com/fastify/github-action-merge-dependabot#readme", 30 | "dependencies": { 31 | "@actions/core": "^1.11.1", 32 | "actions-toolkit": "github:nearform/actions-toolkit" 33 | }, 34 | "devDependencies": { 35 | "@vercel/ncc": "^0.38.4", 36 | "c8": "^10.1.3", 37 | "eslint": "^9.36.0", 38 | "husky": "^9.1.7", 39 | "neostandard": "^0.12.2", 40 | "proxyquire": "^2.1.3", 41 | "sinon": "^21.0.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/action.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const core = require('@actions/core') 4 | const toolkit = require('actions-toolkit') 5 | 6 | const packageInfo = require('../package.json') 7 | const { githubClient } = require('./github-client') 8 | const { logInfo, logWarning, logError } = require('./log') 9 | const { 10 | MERGE_STATUS, 11 | MERGE_STATUS_KEY, 12 | getInputs, 13 | parseCommaOrSemicolonSeparatedValue, 14 | getTarget, 15 | } = require('./util') 16 | const { verifyCommits } = require('./verifyCommitSignatures') 17 | const { dependabotAuthor } = require('./getDependabotDetails') 18 | const { updateTypes } = require('./mapUpdateType') 19 | const { updateTypesPriority } = require('./mapUpdateType') 20 | 21 | module.exports = async function run ({ 22 | github, 23 | context, 24 | inputs, 25 | dependabotMetadata, 26 | }) { 27 | const { updateType } = dependabotMetadata 28 | const dependencyNames = parseCommaOrSemicolonSeparatedValue( 29 | dependabotMetadata.dependencyNames 30 | ) 31 | 32 | const { 33 | MERGE_METHOD, 34 | EXCLUDE_PKGS, 35 | MERGE_COMMENT, 36 | APPROVE_ONLY, 37 | USE_GITHUB_AUTO_MERGE, 38 | TARGET, 39 | TARGET_DEV, 40 | TARGET_PROD, 41 | TARGET_INDIRECT, 42 | PR_NUMBER, 43 | SKIP_COMMIT_VERIFICATION, 44 | SKIP_VERIFICATION, 45 | } = getInputs(inputs) 46 | 47 | try { 48 | toolkit.logActionRefWarning() 49 | 50 | const PULLREQUEST = context.payload.pull_request 51 | if (!PULLREQUEST && !PR_NUMBER) { 52 | core.setOutput(MERGE_STATUS_KEY, MERGE_STATUS.skippedNotADependabotPr) 53 | return logError( 54 | 'This action must be used in the context of a Pull Request or with a Pull Request number' 55 | ) 56 | } 57 | 58 | const client = githubClient(github, context) 59 | const pr = PULLREQUEST || (await client.getPullRequest(PR_NUMBER)) 60 | 61 | const isDependabotPR = pr.user.login === dependabotAuthor 62 | if (!SKIP_VERIFICATION && !isDependabotPR) { 63 | core.setOutput(MERGE_STATUS_KEY, MERGE_STATUS.skippedNotADependabotPr) 64 | return logWarning('Not a dependabot PR, skipping.') 65 | } 66 | 67 | const commits = await client.getPullRequestCommits(pr.number) 68 | if ( 69 | !SKIP_VERIFICATION && 70 | !commits.every(commit => commit.author?.login === dependabotAuthor) 71 | ) { 72 | core.setOutput(MERGE_STATUS_KEY, MERGE_STATUS.skippedNotADependabotPr) 73 | return logWarning('PR contains non dependabot commits, skipping.') 74 | } 75 | 76 | if (!SKIP_COMMIT_VERIFICATION && !SKIP_VERIFICATION) { 77 | try { 78 | verifyCommits(commits) 79 | } catch { 80 | core.setOutput( 81 | MERGE_STATUS_KEY, 82 | MERGE_STATUS.skippedCommitVerificationFailed 83 | ) 84 | return logWarning( 85 | 'PR contains invalid dependabot commit signatures, skipping.' 86 | ) 87 | } 88 | } 89 | 90 | const target = getTarget( 91 | { TARGET, TARGET_DEV, TARGET_PROD, TARGET_INDIRECT }, 92 | dependabotMetadata 93 | ) 94 | 95 | if ( 96 | target !== updateTypes.any && 97 | updateTypesPriority.indexOf(updateType) < 0 98 | ) { 99 | core.setOutput(MERGE_STATUS_KEY, MERGE_STATUS.skippedInvalidVersion) 100 | logWarning(`Semver bump '${updateType}' is invalid!`) 101 | return 102 | } 103 | 104 | if ( 105 | target !== updateTypes.any && 106 | updateTypesPriority.indexOf(updateType) > 107 | updateTypesPriority.indexOf(target) 108 | ) { 109 | core.setOutput(MERGE_STATUS_KEY, MERGE_STATUS.skippedBumpHigherThanTarget) 110 | logWarning(`Semver bump is higher than allowed in TARGET. 111 | Tried to do a '${updateType}' update but the max allowed is '${target}'`) 112 | return 113 | } 114 | 115 | const changedExcludedPackages = EXCLUDE_PKGS.filter( 116 | pkg => dependencyNames.indexOf(pkg) > -1 117 | ) 118 | 119 | // TODO: Improve error message for excluded packages? 120 | if (changedExcludedPackages.length > 0) { 121 | core.setOutput(MERGE_STATUS_KEY, MERGE_STATUS.skippedPackageExcluded) 122 | return logInfo(`${changedExcludedPackages.length} package(s) excluded: \ 123 | ${changedExcludedPackages.join(', ')}. Skipping.`) 124 | } 125 | 126 | if ( 127 | dependencyNames.indexOf(packageInfo.name) > -1 && 128 | updateType === updateTypes.major 129 | ) { 130 | const upgradeMessage = `Cannot automerge ${packageInfo.name} major release. 131 | Read how to upgrade it manually: 132 | https://github.com/fastify/${packageInfo.name}#how-to-upgrade-from-2x-to-new-3x` 133 | 134 | core.setOutput(MERGE_STATUS_KEY, MERGE_STATUS.skippedCannotUpdateMajor) 135 | core.setFailed(upgradeMessage) 136 | return 137 | } 138 | 139 | await client.approvePullRequest(pr.number, MERGE_COMMENT) 140 | if (APPROVE_ONLY) { 141 | core.setOutput(MERGE_STATUS_KEY, MERGE_STATUS.approved) 142 | return logInfo( 143 | 'APPROVE_ONLY set, PR was approved but it will not be merged' 144 | ) 145 | } 146 | 147 | if (USE_GITHUB_AUTO_MERGE) { 148 | await client.enableAutoMergePullRequest(pr.node_id, MERGE_METHOD) 149 | core.setOutput(MERGE_STATUS_KEY, MERGE_STATUS.autoMerge) 150 | return logInfo('USE_GITHUB_AUTO_MERGE set, PR was marked as auto-merge') 151 | } 152 | 153 | await client.mergePullRequest(pr.number, MERGE_METHOD) 154 | core.setOutput(MERGE_STATUS_KEY, MERGE_STATUS.merged) 155 | logInfo('Dependabot merge completed') 156 | } catch (error) { 157 | core.setFailed(error.message) 158 | core.setOutput(MERGE_STATUS_KEY, MERGE_STATUS.mergeFailed) 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/getDependabotDetails.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const dependabotAuthor = 'dependabot[bot]' 3 | 4 | const dependabotCommitter = 'GitHub' 5 | 6 | module.exports = { 7 | dependabotAuthor, 8 | dependabotCommitter, 9 | } 10 | -------------------------------------------------------------------------------- /src/github-client.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | function githubClient (github, context) { 4 | const payload = context.payload 5 | 6 | const repo = payload.repository 7 | const owner = repo.owner.login 8 | const repoName = repo.name 9 | 10 | return { 11 | async getPullRequest (pullRequestNumber) { 12 | const { data: pullRequest } = await github.rest.pulls.get({ 13 | owner, 14 | repo: repoName, 15 | pull_number: pullRequestNumber, 16 | }) 17 | return pullRequest 18 | }, 19 | 20 | async approvePullRequest (pullRequestNumber, approveComment) { 21 | const { data } = await github.rest.pulls.createReview({ 22 | owner, 23 | repo: repoName, 24 | pull_number: pullRequestNumber, 25 | event: 'APPROVE', 26 | body: approveComment, 27 | }) 28 | // todo assert 29 | return data 30 | }, 31 | 32 | async mergePullRequest (pullRequestNumber, mergeMethod) { 33 | const { data } = await github.rest.pulls.merge({ 34 | owner, 35 | repo: repoName, 36 | pull_number: pullRequestNumber, 37 | merge_method: mergeMethod, 38 | }) 39 | // todo assert 40 | return data 41 | }, 42 | 43 | async enableAutoMergePullRequest (pullRequestId, mergeMethod) { 44 | const query = ` 45 | mutation ($pullRequestId: ID!, $mergeMethod: PullRequestMergeMethod!) { 46 | enablePullRequestAutoMerge( 47 | input: { pullRequestId: $pullRequestId, mergeMethod: $mergeMethod } 48 | ) { 49 | pullRequest { 50 | autoMergeRequest { 51 | enabledAt 52 | enabledBy { 53 | login 54 | } 55 | } 56 | } 57 | } 58 | } 59 | ` 60 | const { data } = await github.graphql(query, { 61 | pullRequestId, 62 | mergeMethod: mergeMethod.toUpperCase(), 63 | }) 64 | return data 65 | }, 66 | 67 | async getPullRequestDiff (pullRequestNumber) { 68 | const { data: pullRequest } = await github.rest.pulls.get({ 69 | owner, 70 | repo: repoName, 71 | pull_number: pullRequestNumber, 72 | mediaType: { 73 | format: 'diff', 74 | }, 75 | }) 76 | return pullRequest 77 | }, 78 | 79 | async getPullRequestCommits (pullRequestNumber) { 80 | const { data } = await github.rest.pulls.listCommits({ 81 | owner, 82 | repo: repoName, 83 | pull_number: pullRequestNumber, 84 | }) 85 | 86 | return data 87 | }, 88 | } 89 | } 90 | 91 | module.exports = { githubClient } 92 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const run = require('./action') 4 | 5 | module.exports = run 6 | -------------------------------------------------------------------------------- /src/log.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { debug, error, info, warning } = require('@actions/core') 4 | 5 | const stringify = msg => 6 | typeof msg === 'string' ? msg : msg.stack || msg.toString() 7 | 8 | const log = logger => message => logger(stringify(message)) 9 | 10 | exports.logDebug = log(debug) 11 | exports.logError = log(error) 12 | exports.logInfo = log(info) 13 | exports.logWarning = log(warning) 14 | -------------------------------------------------------------------------------- /src/mapUpdateType.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | // NOTE: fetch-metadata only support the major, minor and patch update types for now, so I removed the `pre` types 4 | const updateTypes = { 5 | major: 'version-update:semver-major', 6 | minor: 'version-update:semver-minor', 7 | patch: 'version-update:semver-patch', 8 | any: 'version-update:semver-any', 9 | } 10 | 11 | const updateTypesPriority = [ 12 | updateTypes.patch, 13 | updateTypes.minor, 14 | updateTypes.major, 15 | updateTypes.any, 16 | ] 17 | 18 | const mapUpdateType = input => { 19 | return updateTypes[input] || updateTypes.any 20 | } 21 | 22 | module.exports = { 23 | mapUpdateType, 24 | updateTypes, 25 | updateTypesPriority, 26 | } 27 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { mapUpdateType } = require('./mapUpdateType') 4 | const { logWarning } = require('./log') 5 | 6 | const mergeMethods = { 7 | merge: 'merge', 8 | squash: 'squash', 9 | rebase: 'rebase', 10 | } 11 | 12 | const getMergeMethod = inputs => { 13 | const input = inputs['merge-method'] 14 | 15 | if (!input) { 16 | return mergeMethods.squash 17 | } 18 | 19 | if (!mergeMethods[input]) { 20 | logWarning( 21 | 'merge-method input is ignored because it is malformed, defaulting to `squash`.' 22 | ) 23 | return mergeMethods.squash 24 | } 25 | 26 | return mergeMethods[input] 27 | } 28 | 29 | const parseCommaOrSemicolonSeparatedValue = value => { 30 | return value ? value.split(/[;,]/).map(el => el.trim()) : [] 31 | } 32 | 33 | exports.parseCommaOrSemicolonSeparatedValue = 34 | parseCommaOrSemicolonSeparatedValue 35 | 36 | exports.getInputs = inputs => { 37 | if (!inputs) { 38 | throw new Error('Invalid inputs object passed to getInputs') 39 | } 40 | 41 | return { 42 | MERGE_METHOD: getMergeMethod(inputs), 43 | EXCLUDE_PKGS: parseCommaOrSemicolonSeparatedValue(inputs['exclude']), 44 | MERGE_COMMENT: inputs['merge-comment'] || '', 45 | APPROVE_ONLY: /true/i.test(inputs['approve-only']), 46 | USE_GITHUB_AUTO_MERGE: /true/i.test(inputs['use-github-auto-merge']), 47 | TARGET: mapUpdateType(inputs['target']), 48 | TARGET_DEV: 49 | inputs['target-development'] && 50 | mapUpdateType(inputs['target-development']), 51 | TARGET_PROD: 52 | inputs['target-production'] && mapUpdateType(inputs['target-production']), 53 | TARGET_INDIRECT: 54 | inputs['target-indirect'] && mapUpdateType(inputs['target-indirect']), 55 | PR_NUMBER: inputs['pr-number'], 56 | SKIP_COMMIT_VERIFICATION: /true/i.test(inputs['skip-commit-verification']), 57 | SKIP_VERIFICATION: /true/i.test(inputs['skip-verification']), 58 | } 59 | } 60 | 61 | exports.getTarget = ( 62 | { TARGET, TARGET_DEV, TARGET_PROD, TARGET_INDIRECT }, 63 | { dependencyType } 64 | ) => { 65 | if (dependencyType === 'direct:development' && TARGET_DEV) { 66 | return TARGET_DEV 67 | } 68 | if (dependencyType === 'direct:production' && TARGET_PROD) { 69 | return TARGET_PROD 70 | } 71 | if (dependencyType === 'indirect' && TARGET_INDIRECT) { 72 | return TARGET_INDIRECT 73 | } 74 | return TARGET 75 | } 76 | 77 | exports.MERGE_STATUS = { 78 | approved: 'approved', 79 | merged: 'merged', 80 | autoMerge: 'auto_merge', 81 | mergeFailed: 'merge_failed', 82 | skippedCommitVerificationFailed: 'skipped:commit_verification_failed', 83 | skippedNotADependabotPr: 'skipped:not_a_dependabot_pr', 84 | skippedCannotUpdateMajor: 'skipped:cannot_update_major', 85 | skippedBumpHigherThanTarget: 'skipped:bump_higher_than_target', 86 | skippedPackageExcluded: 'skipped:packaged_excluded', 87 | skippedInvalidVersion: 'skipped:invalid_semver', 88 | } 89 | 90 | exports.MERGE_STATUS_KEY = 'merge_status' 91 | -------------------------------------------------------------------------------- /src/verifyCommitSignatures.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { 4 | dependabotAuthor, 5 | dependabotCommitter, 6 | } = require('./getDependabotDetails') 7 | 8 | function verifyCommits (commits) { 9 | commits.forEach(function (commit) { 10 | const { 11 | commit: { 12 | verification: { verified }, 13 | committer, 14 | author, 15 | }, 16 | sha, 17 | } = commit 18 | verifyCommitSignatureCommitterAndAuthor(sha, author, committer, verified) 19 | }) 20 | } 21 | 22 | function verifyCommitSignatureCommitterAndAuthor ( 23 | sha, 24 | author, 25 | committer, 26 | verified 27 | ) { 28 | if ( 29 | !verified || 30 | committer.name !== dependabotCommitter || 31 | author.name !== dependabotAuthor 32 | ) { 33 | throw new Error( 34 | `Signature for commit ${sha} could not be verified - Not a dependabot commit` 35 | ) 36 | } 37 | } 38 | 39 | module.exports = { 40 | verifyCommits, 41 | verifyCommitSignatureCommitterAndAuthor, 42 | } 43 | -------------------------------------------------------------------------------- /test/action.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test, afterEach } = require('node:test') 4 | const proxyquire = require('proxyquire') 5 | const sinon = require('sinon') 6 | 7 | // Stubbing modules 8 | const core = require('@actions/core') 9 | const toolkit = require('actions-toolkit') 10 | 11 | const actionLog = require('../src/log') 12 | const actionGithubClient = require('../src/github-client') 13 | const verifyCommits = require('../src/verifyCommitSignatures') 14 | const { updateTypes } = require('../src/mapUpdateType') 15 | const { MERGE_STATUS, MERGE_STATUS_KEY } = require('../src/util') 16 | 17 | const BOT_NAME = 'dependabot[bot]' 18 | 19 | // TODO: Share this stubs with github-client test 20 | const data = 'octokit-result' 21 | const octokitStubs = { 22 | get: sinon.stub().returns(Promise.resolve({ data })), 23 | createReview: sinon.stub().returns(Promise.resolve({ data })), 24 | merge: sinon.stub().returns(Promise.resolve({ data })), 25 | listCommits: sinon.stub().returns(Promise.resolve({ data })), 26 | } 27 | 28 | const githubStub = { 29 | getOctokit: () => ({ 30 | rest: { 31 | pulls: octokitStubs, 32 | }, 33 | }), 34 | } 35 | 36 | const createDependabotMetadata = (props = {}) => ({ 37 | updateType: 'update-version:semver-minor', 38 | dependencyType: 'direct:development', 39 | dependencyNames: 'react', 40 | ...props, 41 | }) 42 | 43 | function buildStubbedAction ({ payload, inputs, dependabotMetadata }) { 44 | const coreStub = sinon.stub(core) 45 | const toolkitStub = sinon 46 | .stub(toolkit, 'logActionRefWarning') 47 | .get(() => sinon.stub()) 48 | 49 | const contextStub = { 50 | payload: { 51 | pull_request: { 52 | user: { 53 | login: 'pr-user-login', 54 | }, 55 | }, 56 | repository: { 57 | owner: { 58 | login: 'owner-login.', 59 | }, 60 | name: 'repository-name', 61 | }, 62 | }, 63 | ...{ payload }, 64 | } 65 | 66 | const logStub = sinon.stub(actionLog) 67 | const prStub = sinon.stub() 68 | const prDiffStub = sinon.stub() 69 | const prCommitsStub = sinon.stub() 70 | const approveStub = sinon.stub() 71 | const mergeStub = sinon.stub() 72 | const enableAutoMergeStub = sinon.stub() 73 | 74 | const clientStub = sinon.stub(actionGithubClient, 'githubClient').returns({ 75 | getPullRequest: prStub.resolves(), 76 | approvePullRequest: approveStub.resolves(), 77 | mergePullRequest: mergeStub.resolves(), 78 | enableAutoMergePullRequest: enableAutoMergeStub.resolves(), 79 | getPullRequestDiff: prDiffStub.resolves(), 80 | getPullRequestCommits: prCommitsStub.resolves([]), 81 | }) 82 | 83 | const verifyCommitsStub = sinon.stub(verifyCommits, 'verifyCommits') 84 | 85 | const action = proxyquire('../src/action', { 86 | '@actions/core': coreStub, 87 | 'actions-toolkit': toolkitStub, 88 | './log': logStub, 89 | './github-client': clientStub, 90 | }) 91 | return { 92 | action: props => 93 | action({ 94 | github: githubStub, 95 | context: contextStub, 96 | inputs: inputs || {}, 97 | dependabotMetadata: createDependabotMetadata(dependabotMetadata), 98 | ...props, 99 | }), 100 | stubs: { 101 | coreStub, 102 | githubStub, 103 | logStub, 104 | prStub, 105 | approveStub, 106 | mergeStub, 107 | enableAutoMergeStub, 108 | prCommitsStub, 109 | verifyCommitsStub, 110 | }, 111 | } 112 | } 113 | 114 | afterEach(() => { 115 | sinon.restore() 116 | }) 117 | 118 | test('should not run if a pull request number is missing', async () => { 119 | const { action, stubs } = buildStubbedAction({ 120 | payload: { issue: {} }, 121 | }) 122 | await action() 123 | sinon.assert.notCalled(stubs.approveStub) 124 | sinon.assert.notCalled(stubs.mergeStub) 125 | 126 | sinon.assert.calledWith( 127 | stubs.coreStub.setOutput, 128 | MERGE_STATUS_KEY, 129 | MERGE_STATUS.skippedNotADependabotPr 130 | ) 131 | }) 132 | 133 | test( 134 | 'should retrieve PR info when trigger by non pull_request events', 135 | async () => { 136 | const PR_NUMBER = Math.random() 137 | const { action, stubs } = buildStubbedAction({ 138 | payload: { 'not a pull_request': {} }, 139 | inputs: { 'pr-number': PR_NUMBER }, 140 | }) 141 | 142 | await action() 143 | 144 | sinon.assert.calledOnce(stubs.prStub) 145 | } 146 | ) 147 | 148 | test('should skip non-dependabot PR', async () => { 149 | const PR_NUMBER = Math.random() 150 | const { action, stubs } = buildStubbedAction({ 151 | payload: { issue: {} }, 152 | inputs: { 'pr-number': PR_NUMBER }, 153 | }) 154 | 155 | stubs.prStub.resolves({ 156 | user: { 157 | login: 'not dependabot', 158 | }, 159 | }) 160 | 161 | await action() 162 | 163 | sinon.assert.calledOnce(stubs.prStub) 164 | sinon.assert.calledWithExactly( 165 | stubs.logStub.logWarning, 166 | 'Not a dependabot PR, skipping.' 167 | ) 168 | sinon.assert.notCalled(stubs.approveStub) 169 | sinon.assert.notCalled(stubs.mergeStub) 170 | sinon.assert.calledWith( 171 | stubs.coreStub.setOutput, 172 | MERGE_STATUS_KEY, 173 | MERGE_STATUS.skippedNotADependabotPr 174 | ) 175 | }) 176 | 177 | const prCommitsStubs = [ 178 | { 179 | author: { 180 | login: 'not dependabot', 181 | }, 182 | }, 183 | { 184 | author: undefined, 185 | }, 186 | ] 187 | 188 | for (const prCommitsStub of prCommitsStubs) { 189 | test('should skip PR with non dependabot commit', async () => { 190 | const PR_NUMBER = Math.random() 191 | const { action, stubs } = buildStubbedAction({ 192 | payload: { 193 | pull_request: { 194 | user: { 195 | login: BOT_NAME, 196 | }, 197 | number: PR_NUMBER, 198 | }, 199 | }, 200 | }) 201 | 202 | stubs.prCommitsStub.resolves([prCommitsStub]) 203 | 204 | await action() 205 | 206 | sinon.assert.calledOnce(stubs.prCommitsStub) 207 | sinon.assert.calledWithExactly( 208 | stubs.logStub.logWarning, 209 | 'PR contains non dependabot commits, skipping.' 210 | ) 211 | sinon.assert.notCalled(stubs.approveStub) 212 | sinon.assert.notCalled(stubs.mergeStub) 213 | 214 | sinon.assert.calledWith( 215 | stubs.coreStub.setOutput, 216 | MERGE_STATUS_KEY, 217 | MERGE_STATUS.skippedNotADependabotPr 218 | ) 219 | }) 220 | } 221 | 222 | for (const prCommitsStub of prCommitsStubs) { 223 | test( 224 | 'should NOT skip PR with non dependabot commit when skip-verification is enabled', 225 | async () => { 226 | const PR_NUMBER = Math.random() 227 | const { action, stubs } = buildStubbedAction({ 228 | payload: { 229 | pull_request: { 230 | user: { 231 | login: BOT_NAME, 232 | }, 233 | number: PR_NUMBER, 234 | }, 235 | }, 236 | inputs: { 237 | 'skip-verification': true, 238 | }, 239 | }) 240 | stubs.prCommitsStub.resolves([prCommitsStub]) 241 | 242 | await action() 243 | 244 | sinon.assert.calledWithExactly( 245 | stubs.logStub.logInfo, 246 | 'Dependabot merge completed' 247 | ) 248 | sinon.assert.calledOnce(stubs.prCommitsStub) 249 | sinon.assert.calledOnce(stubs.approveStub) 250 | sinon.assert.calledOnce(stubs.mergeStub) 251 | 252 | sinon.assert.calledWith( 253 | stubs.coreStub.setOutput, 254 | MERGE_STATUS_KEY, 255 | MERGE_STATUS.merged 256 | ) 257 | } 258 | ) 259 | } 260 | 261 | test( 262 | 'should skip PR if dependabot commit signatures cannot be verified', 263 | async () => { 264 | const PR_NUMBER = Math.random() 265 | const { action, stubs } = buildStubbedAction({ 266 | payload: { 267 | pull_request: { 268 | user: { 269 | login: BOT_NAME, 270 | }, 271 | number: PR_NUMBER, 272 | }, 273 | }, 274 | }) 275 | 276 | stubs.prCommitsStub.resolves([ 277 | { 278 | author: { 279 | login: 'dependabot[bot]', 280 | }, 281 | }, 282 | ]) 283 | 284 | stubs.verifyCommitsStub.throws() 285 | 286 | await action() 287 | 288 | sinon.assert.calledWithExactly( 289 | stubs.logStub.logWarning, 290 | 'PR contains invalid dependabot commit signatures, skipping.' 291 | ) 292 | sinon.assert.notCalled(stubs.approveStub) 293 | sinon.assert.notCalled(stubs.mergeStub) 294 | sinon.assert.calledWith( 295 | stubs.coreStub.setOutput, 296 | MERGE_STATUS_KEY, 297 | MERGE_STATUS.skippedCommitVerificationFailed 298 | ) 299 | } 300 | ) 301 | 302 | test( 303 | 'should review and merge even if commit signatures cannot be verified with skip-commit-verification', 304 | async () => { 305 | const PR_NUMBER = Math.random() 306 | const { action, stubs } = buildStubbedAction({ 307 | payload: { 308 | pull_request: { 309 | user: { 310 | login: BOT_NAME, 311 | }, 312 | number: PR_NUMBER, 313 | }, 314 | }, 315 | inputs: { 316 | 'skip-commit-verification': true, 317 | }, 318 | }) 319 | 320 | stubs.prCommitsStub.resolves([ 321 | { 322 | author: { 323 | login: 'dependabot[bot]', 324 | }, 325 | }, 326 | ]) 327 | 328 | await action() 329 | 330 | sinon.assert.calledWithExactly( 331 | stubs.logStub.logInfo, 332 | 'Dependabot merge completed' 333 | ) 334 | sinon.assert.notCalled(stubs.coreStub.setFailed) 335 | sinon.assert.calledOnce(stubs.approveStub) 336 | sinon.assert.calledOnce(stubs.mergeStub) 337 | sinon.assert.calledWith( 338 | stubs.coreStub.setOutput, 339 | MERGE_STATUS_KEY, 340 | MERGE_STATUS.merged 341 | ) 342 | } 343 | ) 344 | 345 | test( 346 | 'should review and merge even if commit signatures cannot be verified when skip-verification is enabled', 347 | async () => { 348 | const PR_NUMBER = Math.random() 349 | const { action, stubs } = buildStubbedAction({ 350 | payload: { 351 | pull_request: { 352 | user: { 353 | login: BOT_NAME, 354 | }, 355 | number: PR_NUMBER, 356 | }, 357 | }, 358 | inputs: { 359 | 'skip-verification': true, 360 | }, 361 | }) 362 | 363 | stubs.prCommitsStub.resolves([ 364 | { 365 | author: { 366 | login: 'dependabot[bot]', 367 | }, 368 | }, 369 | ]) 370 | 371 | await action() 372 | 373 | sinon.assert.calledWithExactly( 374 | stubs.logStub.logInfo, 375 | 'Dependabot merge completed' 376 | ) 377 | sinon.assert.notCalled(stubs.coreStub.setFailed) 378 | sinon.assert.calledOnce(stubs.approveStub) 379 | sinon.assert.calledOnce(stubs.mergeStub) 380 | 381 | sinon.assert.calledWith( 382 | stubs.coreStub.setOutput, 383 | MERGE_STATUS_KEY, 384 | MERGE_STATUS.merged 385 | ) 386 | } 387 | ) 388 | 389 | test( 390 | 'should review and merge even the user is not dependabot when skip-verification is enabled', 391 | async () => { 392 | const PR_NUMBER = Math.random() 393 | const { action, stubs } = buildStubbedAction({ 394 | payload: { 395 | pull_request: { 396 | user: { 397 | login: BOT_NAME, 398 | }, 399 | number: PR_NUMBER, 400 | }, 401 | }, 402 | inputs: { 403 | 'skip-verification': true, 404 | }, 405 | }) 406 | 407 | stubs.prCommitsStub.resolves([ 408 | { 409 | author: { 410 | login: 'myCustomUser', 411 | }, 412 | }, 413 | ]) 414 | 415 | await action() 416 | 417 | sinon.assert.calledWithExactly( 418 | stubs.logStub.logInfo, 419 | 'Dependabot merge completed' 420 | ) 421 | sinon.assert.notCalled(stubs.coreStub.setFailed) 422 | sinon.assert.calledOnce(stubs.approveStub) 423 | sinon.assert.calledOnce(stubs.mergeStub) 424 | sinon.assert.calledWith( 425 | stubs.coreStub.setOutput, 426 | MERGE_STATUS_KEY, 427 | MERGE_STATUS.merged 428 | ) 429 | } 430 | ) 431 | 432 | test('should ignore excluded package', async () => { 433 | const PR_NUMBER = Math.random() 434 | const { action, stubs } = buildStubbedAction({ 435 | payload: { 436 | pull_request: { 437 | number: PR_NUMBER, 438 | user: { login: BOT_NAME }, 439 | }, 440 | }, 441 | inputs: { 'pr-number': PR_NUMBER, target: 'any', exclude: 'react' }, 442 | }) 443 | 444 | await action() 445 | 446 | sinon.assert.calledWithExactly( 447 | stubs.logStub.logInfo, 448 | '1 package(s) excluded: react. Skipping.' 449 | ) 450 | sinon.assert.notCalled(stubs.approveStub) 451 | sinon.assert.notCalled(stubs.mergeStub) 452 | sinon.assert.calledWith( 453 | stubs.coreStub.setOutput, 454 | MERGE_STATUS_KEY, 455 | MERGE_STATUS.skippedPackageExcluded 456 | ) 457 | }) 458 | 459 | test('approve only should not merge', async () => { 460 | const PR_NUMBER = Math.random() 461 | const { action, stubs } = buildStubbedAction({ 462 | payload: { issue: {} }, 463 | inputs: { 464 | 'pr-number': PR_NUMBER, 465 | target: 'any', 466 | 'approve-only': true, 467 | }, 468 | }) 469 | 470 | stubs.prStub.resolves({ 471 | number: PR_NUMBER, 472 | user: { login: BOT_NAME }, 473 | }) 474 | 475 | stubs.approveStub.resolves({ data: true }) 476 | 477 | await action() 478 | 479 | sinon.assert.calledWithExactly( 480 | stubs.logStub.logInfo, 481 | 'APPROVE_ONLY set, PR was approved but it will not be merged' 482 | ) 483 | sinon.assert.notCalled(stubs.mergeStub) 484 | sinon.assert.calledWith( 485 | stubs.coreStub.setOutput, 486 | MERGE_STATUS_KEY, 487 | MERGE_STATUS.approved 488 | ) 489 | }) 490 | 491 | test('should review and merge', async () => { 492 | const PR_NUMBER = Math.random() 493 | const { action, stubs } = buildStubbedAction({ 494 | payload: { 495 | pull_request: { 496 | number: PR_NUMBER, 497 | user: { login: BOT_NAME }, 498 | }, 499 | }, 500 | inputs: { 'pr-number': PR_NUMBER, target: 'any' }, 501 | }) 502 | 503 | await action() 504 | 505 | sinon.assert.calledWithExactly( 506 | stubs.logStub.logInfo, 507 | 'Dependabot merge completed' 508 | ) 509 | sinon.assert.calledOnce(stubs.approveStub) 510 | sinon.assert.calledOnce(stubs.mergeStub) 511 | sinon.assert.calledWith( 512 | stubs.coreStub.setOutput, 513 | MERGE_STATUS_KEY, 514 | MERGE_STATUS.merged 515 | ) 516 | }) 517 | 518 | test( 519 | 'should merge github-action-merge-dependabot minor release', 520 | async () => { 521 | const PR_NUMBER = Math.random() 522 | const { action, stubs } = buildStubbedAction({ 523 | payload: { 524 | pull_request: { 525 | number: PR_NUMBER, 526 | user: { login: BOT_NAME }, 527 | }, 528 | }, 529 | inputs: { 'pr-number': PR_NUMBER, target: 'any' }, 530 | }) 531 | 532 | await action() 533 | 534 | sinon.assert.calledWithExactly( 535 | stubs.logStub.logInfo, 536 | 'Dependabot merge completed' 537 | ) 538 | sinon.assert.calledOnce(stubs.approveStub) 539 | sinon.assert.calledOnce(stubs.mergeStub) 540 | sinon.assert.calledWith( 541 | stubs.coreStub.setOutput, 542 | MERGE_STATUS_KEY, 543 | MERGE_STATUS.merged 544 | ) 545 | } 546 | ) 547 | 548 | test( 549 | 'should not merge github-action-merge-dependabot major release', 550 | async () => { 551 | const PR_NUMBER = Math.random() 552 | const { action, stubs } = buildStubbedAction({ 553 | payload: { 554 | pull_request: { 555 | number: PR_NUMBER, 556 | user: { login: BOT_NAME }, 557 | }, 558 | }, 559 | inputs: { 'pr-number': PR_NUMBER, target: 'any' }, 560 | dependabotMetadata: { 561 | updateType: updateTypes.major, 562 | dependencyNames: 'github-action-merge-dependabot', 563 | }, 564 | }) 565 | 566 | await action() 567 | 568 | sinon.assert.calledOnce(stubs.coreStub.setFailed) 569 | sinon.assert.notCalled(stubs.approveStub) 570 | sinon.assert.notCalled(stubs.mergeStub) 571 | sinon.assert.calledWith( 572 | stubs.coreStub.setOutput, 573 | MERGE_STATUS_KEY, 574 | MERGE_STATUS.skippedCannotUpdateMajor 575 | ) 576 | } 577 | ) 578 | 579 | test('should review and merge', async () => { 580 | const PR_NUMBER = Math.random() 581 | const { action, stubs } = buildStubbedAction({ 582 | payload: { 583 | pull_request: { 584 | number: PR_NUMBER, 585 | user: { login: BOT_NAME }, 586 | }, 587 | }, 588 | inputs: { 'pr-number': PR_NUMBER, target: 'any' }, 589 | }) 590 | 591 | await action() 592 | 593 | sinon.assert.calledWithExactly( 594 | stubs.logStub.logInfo, 595 | 'Dependabot merge completed' 596 | ) 597 | sinon.assert.notCalled(stubs.coreStub.setFailed) 598 | sinon.assert.calledOnce(stubs.approveStub) 599 | sinon.assert.calledOnce(stubs.mergeStub) 600 | sinon.assert.calledWith( 601 | stubs.coreStub.setOutput, 602 | MERGE_STATUS_KEY, 603 | MERGE_STATUS.merged 604 | ) 605 | }) 606 | 607 | test('should review and enable github auto-merge', async () => { 608 | const PR_NUMBER = Math.random() 609 | const PR_NODE_ID = Math.random() 610 | const { action, stubs } = buildStubbedAction({ 611 | payload: { 612 | pull_request: { 613 | number: PR_NUMBER, 614 | node_id: PR_NODE_ID, 615 | user: { login: BOT_NAME }, 616 | }, 617 | }, 618 | inputs: { 619 | 'pr-number': PR_NUMBER, 620 | target: 'any', 621 | 'use-github-auto-merge': true, 622 | }, 623 | }) 624 | 625 | await action() 626 | 627 | sinon.assert.calledWithExactly( 628 | stubs.logStub.logInfo, 629 | 'USE_GITHUB_AUTO_MERGE set, PR was marked as auto-merge' 630 | ) 631 | sinon.assert.notCalled(stubs.coreStub.setFailed) 632 | sinon.assert.calledOnce(stubs.approveStub) 633 | sinon.assert.calledOnce(stubs.enableAutoMergeStub) 634 | sinon.assert.calledWith( 635 | stubs.coreStub.setOutput, 636 | MERGE_STATUS_KEY, 637 | MERGE_STATUS.autoMerge 638 | ) 639 | }) 640 | 641 | test('should forbid major when target is minor', async () => { 642 | const PR_NUMBER = Math.random() 643 | 644 | const { action, stubs } = buildStubbedAction({ 645 | payload: { 646 | pull_request: { 647 | number: PR_NUMBER, 648 | user: { login: BOT_NAME }, 649 | }, 650 | }, 651 | inputs: { 652 | PR_NUMBER, 653 | target: 'minor', 654 | exclude: 'react', 655 | }, 656 | dependabotMetadata: createDependabotMetadata({ 657 | updateType: updateTypes.major, 658 | }), 659 | }) 660 | 661 | await action() 662 | 663 | sinon.assert.calledWithExactly( 664 | stubs.logStub.logWarning, 665 | `Semver bump is higher than allowed in TARGET. 666 | Tried to do a '${updateTypes.major}' update but the max allowed is '${updateTypes.minor}'` 667 | ) 668 | sinon.assert.notCalled(stubs.approveStub) 669 | sinon.assert.notCalled(stubs.mergeStub) 670 | sinon.assert.calledWith( 671 | stubs.coreStub.setOutput, 672 | MERGE_STATUS_KEY, 673 | MERGE_STATUS.skippedBumpHigherThanTarget 674 | ) 675 | }) 676 | 677 | test('should forbid minor when target is patch', async () => { 678 | const PR_NUMBER = Math.random() 679 | 680 | const { action, stubs } = buildStubbedAction({ 681 | payload: { 682 | pull_request: { 683 | number: PR_NUMBER, 684 | user: { login: BOT_NAME }, 685 | }, 686 | }, 687 | inputs: { 688 | PR_NUMBER, 689 | target: 'patch', 690 | exclude: 'react', 691 | }, 692 | dependabotMetadata: createDependabotMetadata({ 693 | updateType: updateTypes.minor, 694 | }), 695 | }) 696 | 697 | await action() 698 | 699 | sinon.assert.calledWithExactly( 700 | stubs.logStub.logWarning, 701 | `Semver bump is higher than allowed in TARGET. 702 | Tried to do a '${updateTypes.minor}' update but the max allowed is '${updateTypes.patch}'` 703 | ) 704 | sinon.assert.notCalled(stubs.approveStub) 705 | sinon.assert.notCalled(stubs.mergeStub) 706 | sinon.assert.calledWith( 707 | stubs.coreStub.setOutput, 708 | MERGE_STATUS_KEY, 709 | MERGE_STATUS.skippedBumpHigherThanTarget 710 | ) 711 | }) 712 | 713 | test('should forbid when update type is missing', async () => { 714 | const PR_NUMBER = Math.random() 715 | 716 | const { action, stubs } = buildStubbedAction({ 717 | payload: { 718 | pull_request: { 719 | number: PR_NUMBER, 720 | user: { login: BOT_NAME }, 721 | }, 722 | }, 723 | inputs: { 724 | PR_NUMBER, 725 | target: 'minor', 726 | exclude: 'react', 727 | }, 728 | dependabotMetadata: createDependabotMetadata({ 729 | updateType: null, 730 | }), 731 | }) 732 | 733 | await action() 734 | 735 | sinon.assert.calledWithExactly( 736 | stubs.logStub.logWarning, 737 | 'Semver bump \'null\' is invalid!' 738 | ) 739 | sinon.assert.notCalled(stubs.approveStub) 740 | sinon.assert.notCalled(stubs.mergeStub) 741 | sinon.assert.calledWith( 742 | stubs.coreStub.setOutput, 743 | MERGE_STATUS_KEY, 744 | MERGE_STATUS.skippedInvalidVersion 745 | ) 746 | }) 747 | 748 | test('should forbid when update type is not valid', async () => { 749 | const PR_NUMBER = Math.random() 750 | 751 | const { action, stubs } = buildStubbedAction({ 752 | payload: { 753 | pull_request: { 754 | number: PR_NUMBER, 755 | user: { login: BOT_NAME }, 756 | }, 757 | }, 758 | inputs: { 759 | PR_NUMBER, 760 | target: 'minor', 761 | exclude: 'react', 762 | }, 763 | dependabotMetadata: createDependabotMetadata({ 764 | updateType: 'semver:invalid', 765 | }), 766 | }) 767 | 768 | await action() 769 | 770 | sinon.assert.calledWithExactly( 771 | stubs.logStub.logWarning, 772 | 'Semver bump \'semver:invalid\' is invalid!' 773 | ) 774 | sinon.assert.notCalled(stubs.approveStub) 775 | sinon.assert.notCalled(stubs.mergeStub) 776 | sinon.assert.calledWith( 777 | stubs.coreStub.setOutput, 778 | MERGE_STATUS_KEY, 779 | MERGE_STATUS.skippedInvalidVersion 780 | ) 781 | }) 782 | 783 | test('should allow minor when target is major', async () => { 784 | const PR_NUMBER = Math.random() 785 | 786 | const { action, stubs } = buildStubbedAction({ 787 | payload: { 788 | pull_request: { 789 | number: PR_NUMBER, 790 | user: { login: BOT_NAME }, 791 | }, 792 | }, 793 | inputs: { 794 | PR_NUMBER, 795 | target: 'major', 796 | }, 797 | dependabotMetadata: createDependabotMetadata({ 798 | updateType: updateTypes.minor, 799 | }), 800 | }) 801 | 802 | await action() 803 | 804 | sinon.assert.calledWithExactly( 805 | stubs.logStub.logInfo, 806 | 'Dependabot merge completed' 807 | ) 808 | sinon.assert.notCalled(stubs.coreStub.setFailed) 809 | sinon.assert.calledOnce(stubs.approveStub) 810 | sinon.assert.calledOnce(stubs.mergeStub) 811 | sinon.assert.calledWith( 812 | stubs.coreStub.setOutput, 813 | MERGE_STATUS_KEY, 814 | MERGE_STATUS.merged 815 | ) 816 | }) 817 | 818 | test( 819 | 'should pick target-development version for dev package update', 820 | async () => { 821 | const PR_NUMBER = Math.random() 822 | 823 | const { action, stubs } = buildStubbedAction({ 824 | payload: { 825 | pull_request: { 826 | number: PR_NUMBER, 827 | user: { login: BOT_NAME }, 828 | }, 829 | }, 830 | inputs: { 831 | PR_NUMBER, 832 | target: 'minor', 833 | 'target-development': 'major', 834 | }, 835 | dependabotMetadata: createDependabotMetadata({ 836 | updateType: updateTypes.major, 837 | dependencyType: 'direct:development', 838 | }), 839 | }) 840 | 841 | await action() 842 | 843 | sinon.assert.calledWithExactly( 844 | stubs.logStub.logInfo, 845 | 'Dependabot merge completed' 846 | ) 847 | sinon.assert.notCalled(stubs.coreStub.setFailed) 848 | sinon.assert.calledOnce(stubs.approveStub) 849 | sinon.assert.calledOnce(stubs.mergeStub) 850 | sinon.assert.calledWith( 851 | stubs.coreStub.setOutput, 852 | MERGE_STATUS_KEY, 853 | MERGE_STATUS.merged 854 | ) 855 | } 856 | ) 857 | 858 | test( 859 | 'should pick target-production version for production package update', 860 | async () => { 861 | const PR_NUMBER = Math.random() 862 | 863 | const { action, stubs } = buildStubbedAction({ 864 | payload: { 865 | pull_request: { 866 | number: PR_NUMBER, 867 | user: { login: BOT_NAME }, 868 | }, 869 | }, 870 | inputs: { 871 | PR_NUMBER, 872 | 'target-development': 'major', 873 | 'target-production': 'minor', 874 | }, 875 | dependabotMetadata: createDependabotMetadata({ 876 | updateType: updateTypes.major, 877 | dependencyType: 'direct:production', 878 | }), 879 | }) 880 | 881 | await action() 882 | 883 | sinon.assert.calledWithExactly( 884 | stubs.logStub.logWarning, 885 | `Semver bump is higher than allowed in TARGET. 886 | Tried to do a '${updateTypes.major}' update but the max allowed is '${updateTypes.minor}'` 887 | ) 888 | sinon.assert.notCalled(stubs.approveStub) 889 | sinon.assert.notCalled(stubs.mergeStub) 890 | sinon.assert.calledWith( 891 | stubs.coreStub.setOutput, 892 | MERGE_STATUS_KEY, 893 | MERGE_STATUS.skippedBumpHigherThanTarget 894 | ) 895 | } 896 | ) 897 | 898 | test( 899 | 'should pick target-indirect version for indirect package update', 900 | async () => { 901 | const PR_NUMBER = Math.random() 902 | 903 | const { action, stubs } = buildStubbedAction({ 904 | payload: { 905 | pull_request: { 906 | number: PR_NUMBER, 907 | user: { login: BOT_NAME }, 908 | }, 909 | }, 910 | inputs: { 911 | 'pr-number': PR_NUMBER, 912 | target: 'patch', 913 | 'target-indirect': 'major', 914 | }, 915 | dependabotMetadata: createDependabotMetadata({ 916 | updateType: updateTypes.major, 917 | dependencyType: 'indirect', 918 | }), 919 | }) 920 | 921 | await action() 922 | 923 | sinon.assert.calledWithExactly( 924 | stubs.logStub.logInfo, 925 | 'Dependabot merge completed' 926 | ) 927 | sinon.assert.notCalled(stubs.coreStub.setFailed) 928 | sinon.assert.calledOnce(stubs.approveStub) 929 | sinon.assert.calledOnce(stubs.mergeStub) 930 | sinon.assert.calledWith( 931 | stubs.coreStub.setOutput, 932 | MERGE_STATUS_KEY, 933 | MERGE_STATUS.merged 934 | ) 935 | } 936 | ) 937 | 938 | // https://github.com/dependabot/dependabot-core/issues/4893 939 | test( 940 | 'should merge if target-indirect is any and update-type metadata is not set', 941 | async () => { 942 | const PR_NUMBER = Math.random() 943 | 944 | const { action, stubs } = buildStubbedAction({ 945 | payload: { 946 | pull_request: { 947 | number: PR_NUMBER, 948 | user: { login: BOT_NAME }, 949 | }, 950 | }, 951 | inputs: { 952 | 'pr-number': PR_NUMBER, 953 | target: 'patch', 954 | 'target-indirect': 'any', 955 | }, 956 | dependabotMetadata: createDependabotMetadata({ 957 | updateType: '', 958 | dependencyType: 'indirect', 959 | }), 960 | }) 961 | 962 | await action() 963 | 964 | sinon.assert.calledWithExactly( 965 | stubs.logStub.logInfo, 966 | 'Dependabot merge completed' 967 | ) 968 | sinon.assert.notCalled(stubs.coreStub.setFailed) 969 | sinon.assert.calledOnce(stubs.approveStub) 970 | sinon.assert.calledOnce(stubs.mergeStub) 971 | sinon.assert.calledWith( 972 | stubs.coreStub.setOutput, 973 | MERGE_STATUS_KEY, 974 | MERGE_STATUS.merged 975 | ) 976 | } 977 | ) 978 | 979 | test( 980 | 'should pick target version for production package update if target-production is not set', 981 | async () => { 982 | const PR_NUMBER = Math.random() 983 | 984 | const { action, stubs } = buildStubbedAction({ 985 | payload: { 986 | pull_request: { 987 | number: PR_NUMBER, 988 | user: { login: BOT_NAME }, 989 | }, 990 | }, 991 | inputs: { 992 | PR_NUMBER, 993 | 'target-development': 'major', 994 | target: 'patch', 995 | }, 996 | dependabotMetadata: createDependabotMetadata({ 997 | updateType: updateTypes.major, 998 | dependencyType: 'direct:production', 999 | }), 1000 | }) 1001 | 1002 | await action() 1003 | 1004 | sinon.assert.calledWithExactly( 1005 | stubs.logStub.logWarning, 1006 | `Semver bump is higher than allowed in TARGET. 1007 | Tried to do a '${updateTypes.major}' update but the max allowed is '${updateTypes.patch}'` 1008 | ) 1009 | sinon.assert.notCalled(stubs.approveStub) 1010 | sinon.assert.notCalled(stubs.mergeStub) 1011 | sinon.assert.calledWith( 1012 | stubs.coreStub.setOutput, 1013 | MERGE_STATUS_KEY, 1014 | MERGE_STATUS.skippedBumpHigherThanTarget 1015 | ) 1016 | } 1017 | ) 1018 | 1019 | test( 1020 | 'should pick target version for development package update if target-development is not set', 1021 | async () => { 1022 | const PR_NUMBER = Math.random() 1023 | 1024 | const { action, stubs } = buildStubbedAction({ 1025 | payload: { 1026 | pull_request: { 1027 | number: PR_NUMBER, 1028 | user: { login: BOT_NAME }, 1029 | }, 1030 | }, 1031 | inputs: { 1032 | PR_NUMBER, 1033 | 'target-production': 'major', 1034 | target: 'patch', 1035 | }, 1036 | dependabotMetadata: createDependabotMetadata({ 1037 | updateType: updateTypes.major, 1038 | dependencyType: 'direct:development', 1039 | }), 1040 | }) 1041 | 1042 | await action() 1043 | 1044 | sinon.assert.calledWithExactly( 1045 | stubs.logStub.logWarning, 1046 | `Semver bump is higher than allowed in TARGET. 1047 | Tried to do a '${updateTypes.major}' update but the max allowed is '${updateTypes.patch}'` 1048 | ) 1049 | sinon.assert.notCalled(stubs.approveStub) 1050 | sinon.assert.notCalled(stubs.mergeStub) 1051 | sinon.assert.calledWith( 1052 | stubs.coreStub.setOutput, 1053 | MERGE_STATUS_KEY, 1054 | MERGE_STATUS.skippedBumpHigherThanTarget 1055 | ) 1056 | } 1057 | ) 1058 | 1059 | test( 1060 | 'should pick target version for development package update if target-indirect is not set', 1061 | async () => { 1062 | const PR_NUMBER = Math.random() 1063 | 1064 | const { action, stubs } = buildStubbedAction({ 1065 | payload: { 1066 | pull_request: { 1067 | number: PR_NUMBER, 1068 | user: { login: BOT_NAME }, 1069 | }, 1070 | }, 1071 | inputs: { 1072 | PR_NUMBER, 1073 | target: 'patch', 1074 | }, 1075 | dependabotMetadata: createDependabotMetadata({ 1076 | updateType: updateTypes.major, 1077 | dependencyType: 'direct:development', 1078 | }), 1079 | }) 1080 | 1081 | await action() 1082 | 1083 | sinon.assert.calledWithExactly( 1084 | stubs.logStub.logWarning, 1085 | `Semver bump is higher than allowed in TARGET. 1086 | Tried to do a '${updateTypes.major}' update but the max allowed is '${updateTypes.patch}'` 1087 | ) 1088 | sinon.assert.notCalled(stubs.approveStub) 1089 | sinon.assert.notCalled(stubs.mergeStub) 1090 | sinon.assert.calledWith( 1091 | stubs.coreStub.setOutput, 1092 | MERGE_STATUS_KEY, 1093 | MERGE_STATUS.skippedBumpHigherThanTarget 1094 | ) 1095 | } 1096 | ) 1097 | -------------------------------------------------------------------------------- /test/github-client.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test, afterEach } = require('node:test') 4 | const sinon = require('sinon') 5 | const { githubClient } = require('../src/github-client') 6 | 7 | const githubContext = { 8 | repository: { 9 | owner: { 10 | login: 'owner-login.', 11 | }, 12 | name: 'repository-name', 13 | }, 14 | } 15 | 16 | const data = 'octokit-result' 17 | 18 | const octokitStubs = { 19 | get: sinon.stub().returns(Promise.resolve({ data })), 20 | createReview: sinon.stub().returns(Promise.resolve({ data })), 21 | merge: sinon.stub().returns(Promise.resolve({ data })), 22 | listCommits: sinon.stub().returns(Promise.resolve({ data })), 23 | } 24 | const contextStub = { payload: githubContext } 25 | const githubStub = { 26 | rest: { 27 | pulls: octokitStubs, 28 | }, 29 | graphql: sinon.stub().returns(Promise.resolve({ data })), 30 | } 31 | 32 | const PR_NUMBER = Math.floor(Math.random() * 10) 33 | const PR_NODE_ID = Math.floor(Math.random() * 10) 34 | 35 | afterEach(() => { 36 | sinon.resetHistory() 37 | }) 38 | 39 | test('githubClient', async t => { 40 | await t.test('getPullRequest', async (t) => { 41 | const result = await githubClient(githubStub, contextStub).getPullRequest( 42 | PR_NUMBER 43 | ) 44 | t.assert.deepStrictEqual(result, data) 45 | 46 | sinon.assert.calledWith(octokitStubs.get, { 47 | owner: githubContext.repository.owner.login, 48 | repo: githubContext.repository.name, 49 | pull_number: PR_NUMBER, 50 | }) 51 | }) 52 | 53 | await t.test('approvePullRequest', async (t) => { 54 | const comment = 'Test pull request comment' 55 | const result = await githubClient( 56 | githubStub, 57 | contextStub 58 | ).approvePullRequest(PR_NUMBER, comment) 59 | t.assert.deepStrictEqual(result, data) 60 | 61 | sinon.assert.calledWith(octokitStubs.createReview, { 62 | owner: githubContext.repository.owner.login, 63 | repo: githubContext.repository.name, 64 | pull_number: PR_NUMBER, 65 | event: 'APPROVE', 66 | body: comment, 67 | }) 68 | }) 69 | 70 | await t.test('mergePullRequest', async (t) => { 71 | const method = 'squash' 72 | const result = await githubClient(githubStub, contextStub).mergePullRequest( 73 | PR_NUMBER, 74 | method 75 | ) 76 | t.assert.deepStrictEqual(result, data) 77 | 78 | sinon.assert.calledWith(octokitStubs.merge, { 79 | owner: githubContext.repository.owner.login, 80 | repo: githubContext.repository.name, 81 | pull_number: PR_NUMBER, 82 | merge_method: method, 83 | }) 84 | }) 85 | 86 | await t.test('enableAutoMergePullRequest', async (t) => { 87 | const method = 'squash' 88 | const result = await githubClient( 89 | githubStub, 90 | contextStub 91 | ).enableAutoMergePullRequest(PR_NODE_ID, method) 92 | t.assert.deepStrictEqual(result, data) 93 | 94 | const query = ` 95 | mutation ($pullRequestId: ID!, $mergeMethod: PullRequestMergeMethod!) { 96 | enablePullRequestAutoMerge( 97 | input: { pullRequestId: $pullRequestId, mergeMethod: $mergeMethod } 98 | ) { 99 | pullRequest { 100 | autoMergeRequest { 101 | enabledAt 102 | enabledBy { 103 | login 104 | } 105 | } 106 | } 107 | } 108 | } 109 | ` 110 | 111 | sinon.assert.calledWith(githubStub.graphql, query, { 112 | pullRequestId: PR_NODE_ID, 113 | mergeMethod: method.toUpperCase(), 114 | }) 115 | }) 116 | 117 | await t.test('getPullRequestDiff', async (t) => { 118 | const result = await githubClient( 119 | githubStub, 120 | contextStub 121 | ).getPullRequestDiff(PR_NUMBER) 122 | t.assert.deepStrictEqual(result, data) 123 | 124 | sinon.assert.calledWith(octokitStubs.get, { 125 | owner: githubContext.repository.owner.login, 126 | repo: githubContext.repository.name, 127 | pull_number: PR_NUMBER, 128 | mediaType: { 129 | format: 'diff', 130 | }, 131 | }) 132 | }) 133 | 134 | await t.test('getPullRequestCommits', async (t) => { 135 | const result = await githubClient( 136 | githubStub, 137 | contextStub 138 | ).getPullRequestCommits(PR_NUMBER) 139 | t.assert.deepStrictEqual(result, data) 140 | 141 | sinon.assert.calledWith(octokitStubs.listCommits, { 142 | owner: githubContext.repository.owner.login, 143 | repo: githubContext.repository.name, 144 | pull_number: PR_NUMBER, 145 | }) 146 | }) 147 | }) 148 | -------------------------------------------------------------------------------- /test/log.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test, afterEach } = require('node:test') 4 | const sinon = require('sinon') 5 | const proxyquire = require('proxyquire') 6 | 7 | const coreStubs = { 8 | debug: sinon.stub(), 9 | error: sinon.stub(), 10 | info: sinon.stub(), 11 | warning: sinon.stub(), 12 | } 13 | 14 | const log = proxyquire('../src/log', { 15 | '@actions/core': coreStubs, 16 | }) 17 | 18 | afterEach(() => { 19 | sinon.resetHistory() 20 | }) 21 | 22 | test('logError should work with numbers', async () => { 23 | log.logError(100) 24 | sinon.assert.calledWith(coreStubs.error, '100') 25 | }) 26 | 27 | test('logError should work with strings', async () => { 28 | log.logError('100') 29 | sinon.assert.calledWith(coreStubs.error, '100') 30 | }) 31 | 32 | test('log should call aproppriate core function', async () => { 33 | const msg = 'log message' 34 | log.logError(msg) 35 | sinon.assert.calledWith(coreStubs.error, msg) 36 | log.logWarning(msg) 37 | sinon.assert.calledWith(coreStubs.warning, msg) 38 | log.logInfo(msg) 39 | sinon.assert.calledWith(coreStubs.info, msg) 40 | log.logDebug(msg) 41 | sinon.assert.calledWith(coreStubs.debug, msg) 42 | }) 43 | -------------------------------------------------------------------------------- /test/mapUpdateType.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { test } = require('node:test') 3 | 4 | const { updateTypes, mapUpdateType } = require('../src/mapUpdateType') 5 | 6 | test('mapUpdateType', async t => { 7 | t.test( 8 | 'should return the updateType or any if invalid or missing', 9 | async t => { 10 | t.assert.deepEqual(mapUpdateType('major'), updateTypes.major) 11 | t.assert.deepEqual(mapUpdateType('minor'), updateTypes.minor) 12 | t.assert.deepEqual(mapUpdateType('patch'), updateTypes.patch) 13 | t.assert.deepEqual(mapUpdateType('bad_input'), updateTypes.any) 14 | t.assert.deepEqual(mapUpdateType(), updateTypes.any) 15 | t.assert.deepEqual(mapUpdateType('any'), updateTypes.any) 16 | } 17 | ) 18 | }) 19 | -------------------------------------------------------------------------------- /test/util.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { test } = require('node:test') 3 | const sinon = require('sinon') 4 | const proxyquire = require('proxyquire') 5 | 6 | const logWarningStub = sinon.stub() 7 | const { getInputs, parseCommaOrSemicolonSeparatedValue } = proxyquire( 8 | '../src/util', 9 | { 10 | '../src/log.js': { 11 | logWarning: logWarningStub, 12 | }, 13 | } 14 | ) 15 | 16 | test('parseCommaOrSemicolonSeparatedValue', async t => { 17 | await t.test('should split semicolon separated values correctly', async t => { 18 | t.assert.deepStrictEqual(parseCommaOrSemicolonSeparatedValue('test1;test2;test3'), [ 19 | 'test1', 20 | 'test2', 21 | 'test3', 22 | ]) 23 | t.assert.deepStrictEqual(parseCommaOrSemicolonSeparatedValue(' test1; test2; test3'), [ 24 | 'test1', 25 | 'test2', 26 | 'test3', 27 | ]) 28 | }) 29 | await t.test('should split comma separated values correctly', async t => { 30 | t.assert.deepStrictEqual(parseCommaOrSemicolonSeparatedValue('test1,test2,test3'), [ 31 | 'test1', 32 | 'test2', 33 | 'test3', 34 | ]) 35 | t.assert.deepStrictEqual(parseCommaOrSemicolonSeparatedValue(' test1, test2, test3'), [ 36 | 'test1', 37 | 'test2', 38 | 'test3', 39 | ]) 40 | }) 41 | }) 42 | 43 | const BOOLEAN_INPUTS = [ 44 | { input: 'approve-only', key: 'APPROVE_ONLY' }, 45 | { input: 'use-github-auto-merge', key: 'USE_GITHUB_AUTO_MERGE' }, 46 | { 47 | input: 'skip-commit-verification', 48 | key: 'SKIP_COMMIT_VERIFICATION', 49 | }, 50 | { 51 | input: 'skip-verification', 52 | key: 'SKIP_VERIFICATION', 53 | }, 54 | ] 55 | 56 | test('getInputs', async t => { 57 | await t.test('should fail if no inputs object is provided', async t => { 58 | t.assert.throws(() => getInputs()) 59 | }) 60 | await t.test( 61 | 'should return the correct inputs with default value if needed', 62 | async t => { 63 | await t.test('MERGE_METHOD', async t => { 64 | t.assert.deepEqual(getInputs({}).MERGE_METHOD, 'squash') 65 | t.assert.deepEqual(getInputs({ 'merge-method': 'merge' }).MERGE_METHOD, 'merge') 66 | t.assert.deepEqual(logWarningStub.callCount, 0) 67 | t.assert.deepEqual( 68 | getInputs({ 'merge-method': 'invalid-merge-method' }).MERGE_METHOD, 69 | 'squash' 70 | ) 71 | t.assert.deepEqual(logWarningStub.callCount, 1) 72 | t.assert.deepEqual( 73 | logWarningStub.firstCall.args[0], 74 | 'merge-method input is ignored because it is malformed, defaulting to `squash`.' 75 | ) 76 | }) 77 | await t.test('EXCLUDE_PKGS', async t => { 78 | t.assert.deepStrictEqual(getInputs({ exclude: 'react,vue' }).EXCLUDE_PKGS, [ 79 | 'react', 80 | 'vue', 81 | ]) 82 | }) 83 | await t.test('MERGE_COMMENT', async t => { 84 | t.assert.deepEqual(getInputs({}).MERGE_COMMENT, '') 85 | t.assert.deepStrictEqual( 86 | getInputs({ 'merge-comment': 'test-merge-comment' }).MERGE_COMMENT, 87 | 'test-merge-comment' 88 | ) 89 | }) 90 | await t.test('BOOLEAN INPUTS', async t => { 91 | BOOLEAN_INPUTS.forEach(({ input, key }) => { 92 | t.assert.deepEqual(getInputs({})[key], false) 93 | t.assert.deepEqual(getInputs({ [input]: 'false' })[key], false) 94 | t.assert.deepEqual(getInputs({ [input]: 'False' })[key], false) 95 | t.assert.deepEqual(getInputs({ [input]: 'FALSE' })[key], false) 96 | t.assert.deepEqual(getInputs({ [input]: 'true' })[key], true) 97 | t.assert.deepEqual(getInputs({ [input]: 'True' })[key], true) 98 | t.assert.deepEqual(getInputs({ [input]: 'TRUE' })[key], true) 99 | }) 100 | }) 101 | await t.test('TARGET', async t => { 102 | t.assert.deepEqual( 103 | getInputs({ target: 'major' }).TARGET, 104 | 'version-update:semver-major' 105 | ) 106 | t.assert.deepEqual( 107 | getInputs({ target: 'minor' }).TARGET, 108 | 'version-update:semver-minor' 109 | ) 110 | t.assert.deepEqual( 111 | getInputs({ target: 'patch' }).TARGET, 112 | 'version-update:semver-patch' 113 | ) 114 | t.assert.deepEqual(getInputs({ target: '' }).TARGET, 'version-update:semver-any') 115 | t.assert.deepEqual( 116 | getInputs({ target: 'any' }).TARGET, 117 | 'version-update:semver-any' 118 | ) 119 | }) 120 | await t.test('PR_NUMBER', async t => { 121 | t.assert.deepEqual(getInputs({ 'pr-number': '10' }).PR_NUMBER, '10') 122 | }) 123 | } 124 | ) 125 | }) 126 | -------------------------------------------------------------------------------- /test/verifyCommitSignatures.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('node:test') 4 | 5 | const { 6 | dependabotAuthor, 7 | dependabotCommitter, 8 | } = require('../src/getDependabotDetails') 9 | const { 10 | verifyCommits, 11 | verifyCommitSignatureCommitterAndAuthor, 12 | } = require('../src/verifyCommitSignatures') 13 | 14 | const ValidAuthorValidCommitterVerifiedCommitMock = { 15 | commit: { 16 | author: { name: dependabotAuthor }, 17 | committer: { name: dependabotCommitter }, 18 | verification: { verified: true }, 19 | }, 20 | sha: 'sha', 21 | } 22 | const ValidAuthorValidCommitterUnverifiedCommitMock = { 23 | commit: { 24 | author: { name: dependabotAuthor }, 25 | committer: { name: dependabotCommitter }, 26 | verification: { verified: false }, 27 | }, 28 | sha: 'sha', 29 | } 30 | const InvalidAuthorValidCommitterVerifiedCommitMock = { 31 | commit: { 32 | author: { name: 'testUser' }, 33 | committer: { name: dependabotCommitter }, 34 | verification: { verified: true }, 35 | }, 36 | sha: 'sha', 37 | } 38 | const ValidAuthorInvalidCommitterVerifiedCommitMock = { 39 | commit: { 40 | author: { name: dependabotAuthor }, 41 | committer: { name: 'testUser' }, 42 | verification: { verified: true }, 43 | }, 44 | sha: 'sha', 45 | } 46 | const InvalidAuthorInvalidCommitterUnverifiedCommitMock = { 47 | commit: { 48 | author: { name: 'testUser' }, 49 | committer: { name: 'testUser' }, 50 | verification: { verified: false }, 51 | }, 52 | sha: 'sha', 53 | } 54 | 55 | const prCommitsValid = [ 56 | ValidAuthorValidCommitterVerifiedCommitMock, 57 | ValidAuthorValidCommitterVerifiedCommitMock, 58 | ] 59 | const prCommitsInvalid = [ 60 | InvalidAuthorValidCommitterVerifiedCommitMock, 61 | InvalidAuthorValidCommitterVerifiedCommitMock, 62 | ] 63 | const prCommitsValidAndInvalid = [ 64 | ValidAuthorValidCommitterVerifiedCommitMock, 65 | InvalidAuthorValidCommitterVerifiedCommitMock, 66 | ] 67 | 68 | test('verifyCommitSignatureCommitterAndAuthor', async t => { 69 | await t.test('shoud throw error when', async t => { 70 | await t.test('the verified flag is set to false', async t => { 71 | const { 72 | commit: { 73 | author, 74 | committer, 75 | verification: { verified }, 76 | }, 77 | sha, 78 | } = ValidAuthorValidCommitterUnverifiedCommitMock 79 | t.assert.throws( 80 | () => 81 | verifyCommitSignatureCommitterAndAuthor( 82 | sha, 83 | author, 84 | committer, 85 | verified 86 | ), 87 | new Error( 88 | `Signature for commit ${sha} could not be verified - Not a dependabot commit` 89 | ) 90 | ) 91 | }) 92 | 93 | await t.test('the committer name is not GitHub', async t => { 94 | const { 95 | commit: { 96 | author, 97 | committer, 98 | verification: { verified }, 99 | }, 100 | sha, 101 | } = ValidAuthorInvalidCommitterVerifiedCommitMock 102 | t.assert.throws( 103 | () => 104 | verifyCommitSignatureCommitterAndAuthor( 105 | sha, 106 | author, 107 | committer, 108 | verified 109 | ), 110 | new Error( 111 | `Signature for commit ${sha} could not be verified - Not a dependabot commit` 112 | ) 113 | ) 114 | }) 115 | 116 | await t.test('the authot name is not dependabot[bot]', async t => { 117 | const { 118 | commit: { 119 | author, 120 | committer, 121 | verification: { verified }, 122 | }, 123 | sha, 124 | } = InvalidAuthorValidCommitterVerifiedCommitMock 125 | t.assert.throws( 126 | () => 127 | verifyCommitSignatureCommitterAndAuthor( 128 | sha, 129 | author, 130 | committer, 131 | verified 132 | ), 133 | new Error( 134 | `Signature for commit ${sha} could not be verified - Not a dependabot commit` 135 | ) 136 | ) 137 | }) 138 | 139 | await t.test( 140 | 'the committer name is not Github, the author is not dependabot[bot] and is not verified', 141 | async t => { 142 | const { 143 | commit: { 144 | author, 145 | committer, 146 | verification: { verified }, 147 | }, 148 | sha, 149 | } = InvalidAuthorInvalidCommitterUnverifiedCommitMock 150 | t.assert.throws( 151 | () => 152 | verifyCommitSignatureCommitterAndAuthor( 153 | sha, 154 | author, 155 | committer, 156 | verified 157 | ), 158 | new Error( 159 | `Signature for commit ${sha} could not be verified - Not a dependabot commit` 160 | ) 161 | ) 162 | } 163 | ) 164 | }) 165 | 166 | await t.test('should not throw an error when', async t => { 167 | await t.test( 168 | 'the committer name is Github, the author is dependabot[bot] and is verified', 169 | async t => { 170 | const { 171 | commit: { 172 | author, 173 | committer, 174 | verification: { verified }, 175 | }, 176 | sha, 177 | } = ValidAuthorValidCommitterVerifiedCommitMock 178 | t.assert.doesNotThrow( 179 | () => 180 | verifyCommitSignatureCommitterAndAuthor( 181 | sha, 182 | author, 183 | committer, 184 | verified 185 | ), 186 | {} 187 | ) 188 | } 189 | ) 190 | }) 191 | }) 192 | 193 | test('VerifyCommits', async t => { 194 | await t.test('Should throw error when', async t => { 195 | await t.test('At least one commit does not match the requirements', async t => { 196 | t.assert.throws( 197 | () => verifyCommits(prCommitsValidAndInvalid), 198 | new Error( 199 | 'Signature for commit sha could not be verified - Not a dependabot commit' 200 | ) 201 | ) 202 | }) 203 | 204 | await t.test('At least one commit does not match the requirements', async t => { 205 | t.assert.throws( 206 | () => verifyCommits(prCommitsInvalid), 207 | new Error( 208 | 'Signature for commit sha could not be verified - Not a dependabot commit' 209 | ) 210 | ) 211 | }) 212 | }) 213 | await t.test('Should not throw error when', async t => { 214 | await t.test('All commits match the requirements', async t => { 215 | t.assert.doesNotThrow(() => verifyCommits(prCommitsValid), {}) 216 | }) 217 | }) 218 | }) 219 | --------------------------------------------------------------------------------