├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── pull_request.yml │ └── release.yml ├── .gitignore ├── .nvmrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── __tests__ ├── changelog-enforcer.test.js ├── client.test.js ├── context-extractor.test.js ├── env.js ├── label-extractor.test.js ├── test_pull_request.json └── version-extractor.test.js ├── action.yml ├── bin └── cut-release.sh ├── coverage └── badge.svg ├── dist └── index.js ├── eslint.config.mjs ├── example-workflows ├── with-different-changelog-path.yaml ├── with-different-token.yaml ├── with-expected-latest-version-custom-pattern.yaml └── with-expected-latest-version.yaml ├── index.js ├── jest.config.js ├── package-lock.json ├── package.json └── src ├── changelog-enforcer.js ├── client.js ├── context-extractor.js ├── label-extractor.js └── version-extractor.js /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for your interest in contributing! This is a very small and specific project - we would like to keep it that way. 4 | 5 | If you have an idea for a new feature, please open an [issue](https://github.com/dangoslen/changelog-enforcer/issues/new) first and discuss your idea for enhancement. 6 | 7 | If you have run into a problem, likewise open an [issue](https://github.com/dangoslen/changelog-enforcer/issues/new) and we will address it as best as we see fit. 8 | 9 | ## Development 10 | 11 | Currently, this project uses vanilla javascript via `node`. Dependencies are managed via `npm`. 12 | 13 | ### Installing 14 | 15 | After cloning this repository, run 16 | 17 | ``` 18 | npm install 19 | ``` 20 | 21 | ### Building 22 | ``` 23 | npm run package 24 | ``` 25 | 26 | ### Tests 27 | ``` 28 | npm test 29 | ``` 30 | 31 | This will run `npm lint` and lint code with [ESLint](https://eslint.org/) 32 | 33 | ### Changelog 34 | 35 | Any notable changes to functionality or updates to dependencies should be added into the [CHANGELOG](../CHANGELOG.md). For an overview of what to write, take a look at the [KeepAChangelog](https://keepachangelog.com/en/1.0.0/) guide. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **A link to or sample of your workflow** 24 | Provide the workflow you are having issues with 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: dangoslen 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # docs.github.com/en/github/administering-a-repository/configuration-options-for-dependency-updates 2 | version: 2 3 | updates: 4 | - package-ecosystem: "npm" 5 | directory: "/" 6 | labels: 7 | - dependabot 8 | - javascript 9 | - verified 10 | open-pull-requests-limit: 2 11 | schedule: 12 | interval: "weekly" 13 | target-branch: "main" 14 | 15 | - package-ecosystem: "github-actions" 16 | directory: "/" 17 | labels: 18 | - dependabot 19 | - github-actions 20 | - verified 21 | open-pull-requests-limit: 2 22 | schedule: 23 | interval: "weekly" 24 | target-branch: "main" -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: "Pull Request Workflow" 2 | on: 3 | pull_request_target: 4 | types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] 5 | 6 | jobs: 7 | 8 | # validates that the pull request is trusted 9 | verify: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - run: | 13 | VERIFIED_LABEL=${{ contains(github.event.pull_request.labels.*.name, 'verified') }} 14 | if [[ ( $VERIFIED_LABEL == 'false' ) ]]; then 15 | echo "Pull request is not from a trusted source!" 16 | exit 1 17 | fi 18 | 19 | # unit tests 20 | unit-tests: 21 | needs: [ verify ] 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4.1.2 25 | with: 26 | ref: ${{ github.event.pull_request.head.ref }} 27 | repository: ${{ github.event.pull_request.head.repo.full_name }} 28 | 29 | - run: npm install 30 | - run: npm run all 31 | 32 | # test action works running from the graph 33 | enforce-changelog: 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v4.1.2 37 | with: 38 | ref: ${{ github.event.pull_request.head.ref }} 39 | repository: ${{ github.event.pull_request.head.repo.full_name }} 40 | token: ${{ secrets.ACTION_TOKEN }} 41 | 42 | - id: read_version 43 | run: | 44 | echo "version=$(jq -r ".version" package.json)" >> $GITHUB_OUTPUT 45 | echo "tag=v$(jq -r ".version" package.json)" >> $GITHUB_OUTPUT 46 | 47 | - uses: dangoslen/dependabot-changelog-helper@v3 48 | with: 49 | activationLabel: 'dependabot' 50 | 51 | - uses: stefanzweifel/git-auto-commit-action@v5.0.1 52 | with: 53 | commit_message: "Update changelog" 54 | 55 | - id: changelog-enforcer 56 | uses: ./ 57 | with: 58 | skipLabels: "skip-changelog" 59 | expectedLatestVersion: ${{ steps.read_version.outputs.tag }} 60 | 61 | - if: failure() 62 | uses: thollander/actions-comment-pull-request@v2 63 | with: 64 | message: | 65 | Hey @${{ github.event.pull_request.user.login }}, the Changelog Enforcer failed. Can you take a look at the error below and correct it? Thanks! 66 | 67 | ``` 68 | ${{ steps.changelog-enforcer.outputs.errorMessage }} 69 | ``` 70 | comment_tag: "changelog-failed" 71 | 72 | - id: changelog_reader 73 | uses: mindsers/changelog-reader-action@v2 74 | with: 75 | version: "${{ steps.read_version.outputs.tag }}" 76 | path: ./CHANGELOG.md 77 | 78 | - id: check_release 79 | run: | 80 | TAG=$(git ls-remote --tags origin | grep ${{ steps.read_version.outputs.tag }} | cat ) 81 | MISSING=$([[ -z "$TAG" ]] && echo 'true' || echo 'false') 82 | echo "missing=$MISSING" >> $GITHUB_OUTPUT 83 | 84 | - if: ${{ steps.check_release.outputs.missing == 'true' }} 85 | uses: thollander/actions-comment-pull-request@v2 86 | with: 87 | message: | 88 |
89 | Preview of Release Notes to be Created 90 | 91 | ${{ steps.changelog_reader.outputs.changes }} 92 | 93 |
94 | comment_tag: "relase-note-preview" 95 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "Release Workflow" 2 | on: 3 | push: 4 | branches: 5 | - 'releases/v1.6' 6 | - 'releases/v2.3' 7 | - 'releases/v3.3.0' 8 | - 'main' 9 | 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4.1.2 15 | 16 | - name: read versions 17 | id: read-version 18 | run: | 19 | echo "version=$(jq -r ".version" package.json)" >> $GITHUB_OUTPUT 20 | echo "tag=v$(jq -r ".version" package.json)" >> $GITHUB_OUTPUT 21 | echo "major_tag=v$(jq -r ".version" package.json | cut -d '.' -f 1)" >> $GITHUB_OUTPUT 22 | 23 | - name: read changelog entry for version 24 | id: changelog_reader 25 | uses: mindsers/changelog-reader-action@v2 26 | with: 27 | version: "${{ steps.read-version.outputs.tag }}" 28 | path: ./CHANGELOG.md 29 | 30 | - name: check for existing release 31 | id: check_release 32 | run: | 33 | TAG=$(git ls-remote --tags origin | grep ${{ steps.read-version.outputs.tag }} | cat) 34 | MISSING=$([[ -z "$TAG" ]] && echo 'true' || echo 'false') 35 | echo "missing=$MISSING" >> $GITHUB_OUTPUT 36 | 37 | - name: create release 38 | if: ${{ steps.check_release.outputs.missing == 'true' }} 39 | id: create_release 40 | uses: ncipollo/release-action@v1.14.0 41 | with: 42 | tag: "${{ steps.read-version.outputs.tag }}" 43 | name: Changelog Enforcer ${{ steps.read-version.outputs.version }} 44 | body: ${{ steps.changelog_reader.outputs.changes }} 45 | draft: false 46 | prerelease: false 47 | 48 | - name: update major version tag 49 | if: ${{ steps.check_release.outputs.missing == 'true' }} 50 | uses: richardsimko/update-tag@v1 51 | with: 52 | tag_name: "${{ steps.read-version.outputs.major_tag }}" 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | # Editors 4 | .vscode 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Other Dependency directories 41 | jspm_packages/ 42 | 43 | # TypeScript v1 declaration files 44 | typings/ 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Optional eslint cache 50 | .eslintcache 51 | 52 | # Optional REPL history 53 | .node_repl_history 54 | 55 | # Output of 'npm pack' 56 | *.tgz 57 | 58 | # Yarn Integrity file 59 | .yarn-integrity 60 | 61 | # dotenv environment variables file 62 | .env 63 | 64 | # next.js build output 65 | .next 66 | 67 | # Mac 68 | .DS_Store 69 | 70 | !coverage/badge.svg 71 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.11.0 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) 4 | 5 | ## [UNRELEASED] 6 | 7 | ### Dependencies 8 | - Bump `stefanzweifel/git-auto-commit-action` from 5.0.0 to 5.0.1 ([#287](https://github.com/dangoslen/changelog-enforcer/pull/287)) 9 | - Bump `eslint` from 8.57.0 to 9.7.0 ([#288](https://github.com/dangoslen/changelog-enforcer/pull/288)) 10 | 11 | ## [v3.6.1] 12 | ### Changed 13 | - Fix Github Actions Annotations ([#281](https://github.com/dangoslen/changelog-enforcer/pull/281)) 14 | 15 | ### Fixed 16 | - Handle `skipLabels` that contains emojis by properly looking for `:` characters in the label extractor regex (fixes #284) 17 | 18 | ### Dependencies 19 | - Bump `eslint` from 8.56.0 to 8.57.0 ([#282](https://github.com/dangoslen/changelog-enforcer/pull/282)) 20 | - Bump `actions/checkout` from 4.1.1 to 4.1.2 ([#283](https://github.com/dangoslen/changelog-enforcer/pull/283)) 21 | 22 | ## [v3.6.0] 23 | ### Changed 24 | - Now runs on Node 20 25 | - Updates `.nvmrc` to set the version 26 | - Updates node version in `action.yml` 27 | ### Dependencies 28 | - Bump `node-fetch` from 2.6.12 to 2.7.0 ([#264](https://github.com/dangoslen/changelog-enforcer/pull/264), [#270](https://github.com/dangoslen/changelog-enforcer/pull/270)) 29 | - Bump `actions/checkout` from 3.5.3 to 4.1.1 ([#266](https://github.com/dangoslen/changelog-enforcer/pull/266), [#267](https://github.com/dangoslen/changelog-enforcer/pull/267), [#271](https://github.com/dangoslen/changelog-enforcer/pull/271), [#275](https://github.com/dangoslen/changelog-enforcer/pull/275)) 30 | - Bump `@vercel/ncc` from 0.36.1 to 0.38.1 ([#268](https://github.com/dangoslen/changelog-enforcer/pull/268), [#276](https://github.com/dangoslen/changelog-enforcer/pull/276)) 31 | - Bump `jest` from 29.6.2 to 29.7.0 ([#269](https://github.com/dangoslen/changelog-enforcer/pull/269)) 32 | - Bump `stefanzweifel/git-auto-commit-action` from 4.16.0 to 5.0.0 ([#272](https://github.com/dangoslen/changelog-enforcer/pull/272)) 33 | - Bump `@actions/github` from 5.1.1 to 6.0.0 ([#273](https://github.com/dangoslen/changelog-enforcer/pull/273)) 34 | - Bump `@actions/core` from 1.10.0 to 1.10.1 ([#274](https://github.com/dangoslen/changelog-enforcer/pull/274)) 35 | - Bump `eslint` from 8.46.0 to 8.56.0 ([#279](https://github.com/dangoslen/changelog-enforcer/pull/279)) 36 | 37 | ## [v3.5.1] 38 | ### Security 39 | - Removes `uglify-js` and `dist` packages 40 | 41 | ### Dependencies 42 | - Bump `jest` from 29.5.0 to 29.6.2 ([#260](https://github.com/dangoslen/changelog-enforcer/pull/260)) 43 | - Bump `eslint` from 8.42.0 to 8.46.0 ([#261](https://github.com/dangoslen/changelog-enforcer/pull/261)) 44 | 45 | ## [v3.5.0] 46 | ### Dependencies 47 | - Bump `@vercel/ncc` from 0.34.0 to 0.36.1 (#247) 48 | - Bump `eslint` from 8.31.0 to 8.42.0 (#249) 49 | - Bump `actions/checkout` from 3.5.2 to 3.5.3 (#250) 50 | - Bump `node-fetch` from 2.6.9 to 2.6.12 (#251, #253) 51 | 52 | ### Fixed 53 | - Handle `skipLabels` that contain a `/` (#254) 54 | 55 | ## [v3.4.0] 56 | ### Changed 57 | - Switches the default branch from `master` to `main` 58 | 59 | ### Dependencies 60 | - Bump `actions/checkout` from 3.2.0 to 3.5.2 (#245) 61 | - Bump `jest` from 29.3.1 to 29.5.0 (#242) 62 | - Bump `node-fetch` from 2.6.7 to 2.6.9 (#241) 63 | 64 | ## [v3.3.2] 65 | ### Fixed 66 | - Properly rebuilds the `dist.index.js` meant to be built in `v3.3.1`. 67 | 68 | ## [v3.3.1] - YANKED 69 | 70 | _This release has been yanked and should not be used. Please use `v3.3.2` instead. The tag for this release will be deleted on `2023-06-01` and will not be usable after that date. If you are using the `v3` tag, you will get the latest version automatically._ 71 | 72 | ### Fixed 73 | - Removes the deprecated `set-output` command by bumping `@actions/core`. This fixes [issue #222](https://github.com/dangoslen/changelog-enforcer/issues/222) 74 | 75 | ### Dependencies 76 | - Bumps `@vercel/ncc` from 0.33.4 to 0.34.0 77 | - Bumps `stefanzweifel/git-auto-commit-action` from 4.15.4 to 4.16.0 78 | - Bumps `jest` from 29.2.2 to 29.3.1 79 | - Bumps `actions/checkout` from 3.1.0 to 3.2.0 80 | - Bumps `@actions/github` from 5.0.2 to 5.1.1 81 | - Bumps `eslint` from 8.2.0 to 8.31.0 82 | - Bumps `dangoslen/dependabot-changelog-helper` from 2 to 3 83 | - Bumps `@actions/core` from 1.9.0 to 1.10.0 84 | 85 | ## [v3.3.0] 86 | ### Dependencies 87 | - Bumps `stefanzweifel/git-auto-commit-action` from 4.14.1 to 4.15.4 88 | - Bumps `actions/checkout` from 3.0.2 to 3.1.0 89 | - Bumps `@actions/core` from 1.6.0 to 1.9.0 90 | - Bumps `uglify-js` from 3.15.5 to 3.17.4 91 | - Bumps `jest` from 27.3.1 to 29.2.2 92 | 93 | ## [v3.2.1] 94 | ### Changed 95 | - `expectedLatestVersion` no longer enforces validation if the only version in the changelog is an unreleased version. 96 | - See more in the [README](./README.md#expectedlatestversion) 97 | 98 | ## [v3.2.0] 99 | ### Changed 100 | - Now runs on Node 16 101 | - Adds `.nvmrc` to set the version 102 | - Updates node version in `action.yml` 103 | ### Dependencies 104 | - Bumps `uglify-js` from 3.14.3 to 3.15.5 105 | - Bumps `@actions/github` from 5.0.0 to 5.0.2 106 | - Bumps `stefanzweifel/git-auto-commit-action` from 4.14.0 to 4.14.1 107 | 108 | ## [v3.1.0] 109 | ### Fixes 110 | - Fixes issue #184 111 | - Get changelog from the `contents_url` instead of the `raw_url` 112 | ### Dependencies 113 | - Bumps `actions/checkout` from 2.4.0 to 3.0.2 114 | - Bumps `stefanzweifel/git-auto-commit-action` from 4.13.1 to 4.14.0 115 | - Removed `@actions/exec` 116 | - Bumps `@vercel/ncc` from 0.31.1 to 0.33.4 117 | 118 | ## [v3.0.1] 119 | ### Dependencies 120 | - Bumps `stefanzweifel/git-auto-commit-action` from 4.11.0 to 4.13.1 121 | - Bumps `@vercel/ncc` from 0.31.1 to 0.33.4 122 | 123 | ## [v3.0.0] 124 | :rocket: The 3.0.0 release of the Changelog Enforcer is here! This release relies soley on the GitHub API instead of local git commands from a cloned repository. This means, for example, that `actions/checkout` does **not** need to be run before running the enforcer. 125 | ### Fixes 126 | - Fixes issue #142 127 | ### Dependencies 128 | - Bumps `@vercel/ncc` from 0.28.6 to 0.31.1 129 | - Bumps `@actions/core` from 1.4.0 to 1.6.0 130 | - Bumps `jest` from 27.0.5 to 27.3.1 131 | - Bumps `actions/checkout` from 2.3.4 to 2.4.0 132 | - Bumps `uglify-js` from 3.13.9 to 3.14.3 133 | - Bumps `eslint` from 7.28.0 to 8.2.0 134 | 135 | ## [v2.3.1] 136 | ### Changed 137 | - Only runs on `pull_request` and `pull_request_target` events. This is to address issue #140 138 | 139 | ## [v2.3.0] 140 | ### Dependencies 141 | - Bumps `lodash` from 4.17.19 to 4.17.21 142 | - Bumps `stefanzweifel/git-auto-commit-action` from 4 to 4.11.0 143 | - Bumps `actions/checkout` from 2 to 2.3.4 144 | - Bumps `actions/create-release` from 1 to 1.1.4 145 | - Bumps `uglify-js` from 3.13.3 to 3.13.9 146 | - Bumps `eslint` from 7.25.0 to 7.28.0 147 | - Bumps `@vercel/ncc` from 0.28.2 to 0.28.6 148 | - Bumps `@actions/github` from 4.0.0 to 5.0.0 149 | - Bumps `dangoslen/dependabot-changelog-helper` from 0.3.2 to 1 150 | - Bumps `@actions/exec` from 1.0.4 to 1.1.0 151 | - Bumps `@actions/core` from 1.2.7 to 1.4.0 152 | - Bumps `jest` from 26.6.3 to 27.0.5 153 | - Bumps `ws` from 7.4.0 to 7.5.3 154 | 155 | ## [v2.2.0] 156 | ### Changed 157 | - The `pull_request` workflow now executes as a `pull_request_target` workflow to handle incoming pull requests from forked repos. 158 | - This is needed because Dependabot now works as a [forked branch](https://github.blog/changelog/2021-02-19-github-actions-workflows-triggered-by-dependabot-prs-will-run-with-read-only-permissions/). The reasoning and ways to accommodate are listed in a [GitHub Security article](https://securitylab.github.com/research/github-actions-preventing-pwn-requests/) 159 | - The `verified` label is needed to allow the workflow to execute 160 | ### Dependencies 161 | - Bumps `uglify-js` from 3.13.2 to 3.13.3 162 | - Bumps `y18n` from 4.0.1 to 5.0.8 163 | - Bumps `@vercel/ncc` from 0.27.0 to 0.28.2 164 | - Bumps `@actions/core` from 1.2.6 to 1.2.7 165 | - Bumps `eslint` from 7.23.0 to 7.25.0 166 | - Bumps `hosted-git-info` from 2.8.8 to 2.8.9 167 | 168 | ## [v2.1.0] 169 | ### Deprecated 170 | - The input `versionPattern` is now deprecated. Starting in `v3.0.0` the Changelog Enforcer will only work with [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) for verifying the latest expected version. 171 | ### Dependencies 172 | - Bumps `eslint` from 7.21.0 to 7.23.0 173 | - Bumps `uglify-js` from 3.13.0 3.13.2 174 | 175 | ## [v2.0.2] 176 | ### Changed 177 | - Minor changes to workflows to use `package.json` version 178 | - Minor changes to tests and names 179 | ### Dependencies 180 | - Bumps `uglify-js` from 3.12.1 to 3.13.0 181 | - Bumps `eslint` from 7.20.0 to 7.21.0 182 | 183 | ## [v2.0.1] 184 | ### Dependencies 185 | - Bump `eslint` from 7.17.0 to 7.20.0 186 | - Bump `@vercel/ncc` from 0.26.1 to 0.27.0 187 | ### Changed 188 | - Now reads the version from `package.json` instead of from `VERSION` 189 | 190 | ## [v2.0.0] 191 | ### Added 192 | - Feature request from #62 193 | - Adds a new property `missingUpdateErrorMessage` for passing a custom error message when no update is found to the changelog. See the [Inputs / Properties](https://github.com/dangoslen/changelog-enforcer#inputs--properties) section in the `README.md` for more information. 194 | - Adds a new output `errorMessage` that states why the Changelog Enforcer failed. Added to allow users to use the error message within the rest of the action workflow. 195 | ### Dependencies 196 | - Bumps `@vercel/ncc` from `0.25.1` to `0.26.1` (#63) 197 | - Bumps `eslint` from `7.15.0` to `7.17.0` (#64, #70) 198 | - Bumps `node-notifier` from `8.0.0` to `8.0.1` (#65) 199 | 200 | ## [v1.6.1] 201 | ### Fixed 202 | - Fixes #58 by properly accounting for whitespace characters in label names. 203 | 204 | ## [v1.6.0] 205 | ### Added 206 | - New `skipLabels` input variable to supply a list of labels to skip enforcement for. See the [Inputs / Properties](https://github.com/dangoslen/changelog-enforcer#inputs--properties) section in the `README.md` for more information. 207 | ### Changed 208 | - Deprecates the `skipLabel` input variable in favor of the `skipLabels` input variable 209 | ### Dependencies 210 | - `eslint` from `7.14.0` to `7.15.0` 211 | - `uglify-js` from `2.6.0` to `3.12.1` 212 | - `jest` from `24.9.0` to `26.6.3` 213 | 214 | ## [v1.5.1] 215 | ### Added 216 | - Improved GitHub actions workflow for testing and packaging 217 | - Preview of release notes for a new version 218 | ### Dependencies 219 | - `@actions/exec` from `1.0.3` to `1.0.4` 220 | - `@actions/github` from `2.1.1` to `4.0.0` 221 | - `eslint` from `6.3.0` to `7.14.0` 222 | - `changelog-reader-action` from `v1` to `v2` 223 | 224 | ## [v1.5.0] 225 | ### Added 226 | - New input parameter `expectedLatestVersion`. 227 | - When supplied, the Changelog Enforcer validates that this is the latest version in the changelog or the latest version after an "Unreleased" version if one exists. 228 | - New input parameter `versionPattern`. 229 | - Used in conjunction with `expectedLatestVersion`. This is a javascript string that is converted to a regular expression that is used to extract the versions in the changelog identified by the `changeLogPath` input. By default is uses a regular expression for the [KeepAChangelog.org format](https://keepachangelog.com/en/1.0.0/). 230 | ### Changed 231 | - Updates to `README` and `CHANGELOG` for new features 232 | 233 | ## [v1.4.1] 234 | ### Security 235 | - `@actions/core@1.1.1` to `@actions/core@1.2.6` 236 | ### Adds 237 | - Badge for workflows using this action 238 | 239 | ## [v1.4.0] 240 | ### Summary 241 | Please upgrade to use with `actions/checkout@v2`! 242 | ### Fixes 243 | - Now works with both `actions/checkout@v1` and `actions/checkout@v2` 244 | ### Adds 245 | - Code coverage checks via `jest` and coverage badge via `make-coverage-badge` 246 | 247 | ## [v1.3.0] 248 | ### Security 249 | - `node-fetch@2.6.0` to `node-fetch@2.6.1` 250 | - `yargs-parser@13.1.1` to `yargs-parser@13.1.2` 251 | 252 | ## [v1.2.0] 253 | ### Added 254 | - Automatically builds the distribution on pull requests if all tests and enforcement pass 255 | 256 | ### Updated 257 | - Small `README` updates 258 | 259 | ## [v1.1.2] 260 | ### Security 261 | - `lodash@4.17.15` to `lodash@4.17.19` 262 | 263 | ## [v1.1.1] 264 | ### Fixes 265 | - Referencing proper step id in workflow for creating releases 266 | 267 | ## [v1.1.0] 268 | ### Added 269 | - Using [Changelog Reader](https://github.com/marketplace/actions/changelog-reader) to automate creating GitHub Releases from this `CHANGELOG.md` 270 | 271 | ## [v1.0.2] 272 | ### Security 273 | - Update uglify-js to 2.6.0 per [CVE-2015-8857](https://github.com/advisories/GHSA-34r7-q49f-h37c) 274 | 275 | ## [v1.0.1] 276 | ### Fixed 277 | - Fixes spelling of `skipLabel` property in `README.md` 278 | 279 | ## [v1.0.0] 280 | ### Added 281 | - Adds updates to the `README.md` and `action.yaml` to prepare to the GitHub marketplace 282 | 283 | ## [v0.1.0] 284 | - Initial `Changelog Enforcer` functionality, including the use of a label to skip 285 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 GitHub Actions 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | unit tests badge 3 | latest version 4 | coverage badge 5 |

6 | 7 | ## Changelog Enforcer 8 | 9 | The purpose of this action is to enforce that every pull request in a repository includes a change to an ongoing changelog file. Inspired by [KeepAChangelog](https://keepachangelog.com/en/1.0.0/), this action helps development teams to keep a change file up to date as new features or fixes are implemented. 10 | 11 | ### Usage 12 | 13 | To use this action, follow the typical GitHub Action `uses` syntax. An example workflow using the default parameters of this action is shown below: 14 | 15 | ```yaml 16 | name: "Pull Request Workflow" 17 | on: 18 | pull_request: 19 | # The specific activity types are listed here to include "labeled" and "unlabeled" 20 | # (which are not included by default for the "pull_request" trigger). 21 | # This is needed to allow skipping enforcement of the changelog in PRs with specific labels, 22 | # as defined in the (optional) "skipLabels" property. 23 | types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] 24 | 25 | jobs: 26 | # Enforces the update of a changelog file on every pull request 27 | changelog: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: dangoslen/changelog-enforcer@v3 31 | ``` 32 | 33 | Other examples can be seen in the [example-workflows](./example-workflows) directory in this repository. 34 | 35 | _:warning: The Changelog Enforcer is designed to be used with the `pull_request` or `pull_request_target` event types. Using this action on any other event type will result in a warning logged and the action succeeding (as to not block the rest of a workflow)._ 36 | 37 | ### Inputs / Properties 38 | 39 | Below are the properties allowed by the Changelog Enforcer. These properties are shipped with sane defaults for typical use, especially for changelogs inline with the [KeepAChangelog](Keepachangelog.org) format. 40 | 41 | #### `changeLogPath` 42 | * Default: `CHANGELOG.md` 43 | * The path to your changelog file. Should be from the perspective of the root directory to `git`. The file being checked for updates must be either an add (`A`) or modified (`M`) status to `git` to qualify as updated. 44 | 45 | #### `skipLabels` 46 | * Default: `'Skip-Changelog'` 47 | * List of labels used to skip enforcing of the changelog during a pull request. Each label name is comma separated and only one label needs to be present for enforcement to be skipped. 48 | 49 | For example, if `label-1,label-2` was supplied as the `skipLabels`, `label-1` _or_ `label-2` would skip the enforcer. Each label is trimmed for leading and trailing spaces since GitHub labels do not allow for leading or trailing spaces. Thus, the following lists are equivalent: 50 | * `label-1,label-2` 51 | * `label-1 , label-2` 52 | * `label-1 ,label-2` 53 | 54 | #### `missingUpdateErrorMessage` 55 | * Default: `''` 56 | * Custom error message to use when no update to the changelog is found. 57 | 58 | #### `expectedLatestVersion` 59 | * Default: `''` 60 | * The latest version of the software expected in the changelog. Should be in the form of `v1.1.0`, `v3.5.6` etc. Allows for the first version in the changelog to be an unreleased version (either `unreleased|Unreleased|UNRELEASED`) before checking versions. If the only version in the changelog is an unreleased version, no validation occurs. This is to support a repository adding a changelog after other versions have been released and don't want to backport previous versions (though doing so is recommended). 61 | 62 | #### `versionPattern` 63 | * Default: `'## \\[((v|V)?\\d*\\.\\d*\\.\\d*-?\\w*|unreleased|Unreleased|UNRELEASED)\\]'` 64 | * A regex pattern used to extract the version section headings from the changelog. The Changelog Enforcer assumes the use of the [KeepAChangelog.com](https://keepachangelog.com/en/1.0.0/) convention for section headings, and as such looks for a line starting with `## [version] - date`. Versions are only extracted from the changelog when enforcing the expected latest version (via the `expectedLatestVersion` property). 65 | 66 | If you supply your own regex to match a different format, your regex must match the version string as a capture group (in the default format, that's the part inside square brackets). The first capture group will be used if your regex includes multiple groups. The regex pattern is used with global and multiline flags to find all of the versions in the changelog. 67 | 68 | Because the regex is passed as a `String` object, you will need to escape backslash characters (`\`) via `\\`. 69 | 70 | #### `token` 71 | * Default: `${{ github.token }}` 72 | * The token used to authenticate to the GitHub API. Uses the default token from the `github.token` context. Can be any access token you have configured for your repository. 73 | 74 | ### Outputs 75 | 76 | #### `errorMessage` 77 | * The reason for why the Changelog Enforcer failed. Uses the `missingUpdateErrorMessage` property value if set when no update to the changelog is found. 78 | 79 | ### Creating Releases Automatically 80 | 81 | Using this Action and the [Changelog Reader](https://github.com/mindsers/changelog-reader-action), plus a few standard GitHub created Actions, we can keep the changelog of a project up to date and create a GitHub release automatically with contents from the changelog. See this project's [release.yml](./.github/workflows/release.yml) for how to set up a simple workflow to create a new release based on a `VERSION` file and a changelog. 82 | -------------------------------------------------------------------------------- /__tests__/changelog-enforcer.test.js: -------------------------------------------------------------------------------- 1 | jest.mock('node-fetch'); 2 | 3 | const core = require('@actions/core') 4 | const fetch = require('node-fetch') 5 | const { Response } = jest.requireActual('node-fetch'); 6 | const changelogEnforcer = require('../src/changelog-enforcer') 7 | 8 | const SKIP_LABELS = "SomeLabel,Skip-Changelog,Skip-Release" 9 | const CHANGELOG = "CHANGELOG.md" 10 | const VERSION_PATTERN = "^## \\[((v|V)?\\d*\\.\\d*\\.\\d*-?\\w*|unreleased|Unreleased|UNRELEASED)\\]" 11 | 12 | // Inputs for mock @actions/core 13 | let inputs = {} 14 | 15 | // Mocks via Jest 16 | let infoSpy 17 | let failureSpy 18 | let outputSpy 19 | 20 | describe('the changelog-enforcer', () => { 21 | 22 | afterAll(() => { 23 | jest.restoreAllMocks() 24 | }) 25 | 26 | beforeEach(() => { 27 | jest.clearAllMocks() 28 | 29 | inputs['skipLabels'] = SKIP_LABELS 30 | inputs['changeLogPath'] = CHANGELOG 31 | inputs['expectedLatestVersion'] = '' 32 | inputs['versionPattern'] = VERSION_PATTERN 33 | inputs['token'] = 'token' 34 | 35 | jest.spyOn(core, 'getInput').mockImplementation((name) => { 36 | return inputs[name] 37 | }) 38 | 39 | octokit = {} 40 | 41 | infoSpy = jest.spyOn(core, 'info').mockImplementation(jest.fn()) 42 | failureSpy = jest.spyOn(core, 'setFailed').mockImplementation(jest.fn()) 43 | outputSpy = jest.spyOn(core, 'setOutput').mockImplementation(jest.fn()) 44 | }) 45 | 46 | prepareResponse = (body) => { 47 | return Promise.resolve(new Response(body, { Headers: { 'Content-Type': 'application/json' } })) 48 | } 49 | 50 | it('should skip enforcing when label is present', (done) => { 51 | changelogEnforcer.enforce() 52 | .then(() => { 53 | expect(infoSpy).toHaveBeenCalledTimes(5) 54 | expect(failureSpy).not.toHaveBeenCalled() 55 | expect(outputSpy).not.toHaveBeenCalled() 56 | 57 | done() 58 | }) 59 | }) 60 | 61 | it('should throw an error when token is missing', (done) => { 62 | inputs['token'] = '' 63 | 64 | changelogEnforcer.enforce() 65 | .then(() => { 66 | expect(infoSpy).not.toHaveBeenCalled() 67 | expect(failureSpy).toHaveBeenCalled() 68 | expect(outputSpy).toHaveBeenCalled() 69 | 70 | done() 71 | }) 72 | }) 73 | 74 | it('should enforce when label is not present; changelog is changed', (done) => { 75 | inputs['skipLabels'] = 'A different label' 76 | 77 | const files = [ 78 | { 79 | "filename": "CHANGELOG.md", 80 | "status": "modified", 81 | "contents_url": "./path/to/CHANGELOG.md" 82 | } 83 | ] 84 | 85 | fetch.mockImplementation((url, options) => { 86 | return prepareResponse(JSON.stringify(files)) 87 | }) 88 | 89 | changelogEnforcer.enforce() 90 | .then(() => { 91 | expect(infoSpy).toHaveBeenCalledTimes(5) 92 | expect(failureSpy).not.toHaveBeenCalled() 93 | expect(outputSpy).not.toHaveBeenCalled() 94 | 95 | expect(fetch).toHaveBeenCalledTimes(1) 96 | 97 | done() 98 | }) 99 | }) 100 | 101 | it('should enforce when label is not present; changelog is not changed', (done) => { 102 | inputs['skipLabels'] = 'A different label' 103 | 104 | const files = [ 105 | { 106 | "filename": "AnotherFile.md", 107 | "status": "modified", 108 | "contents_url": "/path/to/AnotherFile.md" 109 | } 110 | ] 111 | 112 | 113 | fetch.mockImplementation((url, options) => { 114 | return prepareResponse(JSON.stringify(files)) 115 | }) 116 | 117 | changelogEnforcer.enforce() 118 | .then(() => { 119 | expect(infoSpy).toHaveBeenCalledTimes(5) 120 | expect(failureSpy).toHaveBeenCalled() 121 | expect(outputSpy).toHaveBeenCalled() 122 | 123 | expect(fetch).toHaveBeenCalledTimes(1) 124 | 125 | done() 126 | }) 127 | }) 128 | 129 | it('should enforce when label is not present; changelog is not changed; custom error message', (done) => { 130 | const customErrorMessage = 'Some Message for you @Author!' 131 | inputs['skipLabels'] = 'A different label' 132 | inputs['missingUpdateErrorMessage'] = customErrorMessage 133 | 134 | const files = [ 135 | { 136 | "filename": "AnotherFile.md", 137 | "status": "modified", 138 | "contents_url": "/path/to/AnotherFile.md" 139 | } 140 | ] 141 | 142 | fetch.mockImplementation((url, options) => { 143 | return prepareResponse(JSON.stringify(files)) 144 | }) 145 | 146 | changelogEnforcer.enforce() 147 | .then(() => { 148 | expect(infoSpy).toHaveBeenCalledTimes(5) 149 | expect(failureSpy).toHaveBeenCalled() 150 | expect(outputSpy).toHaveBeenCalledWith('errorMessage', customErrorMessage) 151 | 152 | expect(fetch).toHaveBeenCalledTimes(1) 153 | 154 | done() 155 | }) 156 | }) 157 | 158 | it('should enforce when label is not present; changelog is changed; versions do not match', (done) => { 159 | const contentsUrl = 'some-url' 160 | inputs['skipLabels'] = 'A different label' 161 | inputs['expectedLatestVersion'] = 'v2.0.0' 162 | 163 | const files = [ 164 | { 165 | "filename": "CHANGELOG.md", 166 | "status": "modified", 167 | "contents_url": contentsUrl 168 | } 169 | ] 170 | 171 | const changelog = 172 | `## [v2.1.0] 173 | - Changelog 174 | ` 175 | 176 | fetch.mockImplementation((url, options) => { 177 | if (url === contentsUrl) { 178 | return Promise.resolve(new Response(changelog)) 179 | } 180 | return prepareResponse(JSON.stringify(files)) 181 | }) 182 | 183 | changelogEnforcer.enforce() 184 | .then(() => { 185 | expect(infoSpy).toHaveBeenCalledTimes(5) 186 | expect(failureSpy).toHaveBeenCalled() 187 | expect(outputSpy).toHaveBeenCalled() 188 | 189 | expect(fetch).toHaveBeenCalledTimes(2) 190 | 191 | done() 192 | }) 193 | }) 194 | 195 | it('should enforce when label is not present; changelog is changed; only one unreleased version exists', (done) => { 196 | const contentsUrl = 'some-url' 197 | inputs['skipLabels'] = 'A different label' 198 | inputs['expectedLatestVersion'] = 'v2.0.0' 199 | 200 | const files = [ 201 | { 202 | "filename": "CHANGELOG.md", 203 | "status": "modified", 204 | "contents_url": contentsUrl 205 | } 206 | ] 207 | 208 | const changelog = 209 | `## [Unreleased] 210 | - Changelog 211 | ` 212 | 213 | fetch.mockImplementation((url, options) => { 214 | if (url === contentsUrl) { 215 | return Promise.resolve(new Response(changelog)) 216 | } 217 | return prepareResponse(JSON.stringify(files)) 218 | }) 219 | 220 | changelogEnforcer.enforce() 221 | .then(() => { 222 | expect(infoSpy).toHaveBeenCalledTimes(5) 223 | expect(failureSpy).not.toHaveBeenCalled() 224 | expect(outputSpy).not.toHaveBeenCalled() 225 | 226 | expect(fetch).toHaveBeenCalledTimes(2) 227 | 228 | done() 229 | }) 230 | }) 231 | }) -------------------------------------------------------------------------------- /__tests__/client.test.js: -------------------------------------------------------------------------------- 1 | jest.mock('node-fetch'); 2 | 3 | const fetch = require('node-fetch') 4 | const { Response } = jest.requireActual('node-fetch'); 5 | const client = require('../src/client') 6 | 7 | describe('the client', () => { 8 | 9 | afterAll(() => { 10 | jest.restoreAllMocks() 11 | }) 12 | 13 | beforeEach(() => { 14 | jest.clearAllMocks() 15 | }) 16 | 17 | prepareResponse = (body) => { 18 | return Promise.resolve(new Response(body, { Headers: { 'Content-Type': 'application/json' } })) 19 | } 20 | 21 | it('should find the change file', async () => { 22 | const files = [ 23 | { 24 | "filename": "CHANGELOG.md", 25 | "status": "modified", 26 | "contents_url": "./path/to/CHANGELOG.md" 27 | } 28 | ] 29 | 30 | fetch.mockReturnValueOnce(prepareResponse(JSON.stringify(files))) 31 | 32 | const changelogFile = await client.findChangelog('token', 'repo', 1, 1, 'CHANGELOG.md') 33 | expect(fetch).toHaveBeenCalled() 34 | expect(changelogFile).toStrictEqual({ 35 | "filename": "CHANGELOG.md", 36 | "status": "modified", 37 | "contents_url": "./path/to/CHANGELOG.md" 38 | }) 39 | }) 40 | 41 | it('should not find the change file', async () => { 42 | const firstPage = [ 43 | { 44 | "filename": "random.md", 45 | "status": "modified", 46 | "contents_url": "./path/to/random.md" 47 | } 48 | ] 49 | 50 | const secondPage = [] 51 | 52 | fetch 53 | .mockReturnValueOnce(prepareResponse(JSON.stringify(firstPage))) 54 | .mockReturnValueOnce(prepareResponse(JSON.stringify(secondPage))) 55 | 56 | const changelogFile = await client.findChangelog('token', 'repo', 1, 1, 'CHANGELOG.md') 57 | expect(fetch).toHaveBeenCalledTimes(2) 58 | expect(changelogFile).toBeUndefined() 59 | }) 60 | 61 | it('should get an error with bad response code', async () => { 62 | fetch 63 | .mockReturnValueOnce(Promise.resolve(new Response("", { status: 401 }))) 64 | 65 | try { 66 | await client.findChangelog('token', 'repo', 1, 1, 'CHANGELOG.md') 67 | } catch (err) { 68 | expect(fetch).toHaveBeenCalled() 69 | } 70 | }) 71 | }) -------------------------------------------------------------------------------- /__tests__/context-extractor.test.js: -------------------------------------------------------------------------------- 1 | const contextExtractor = require('../src/context-extractor') 2 | const core = require('@actions/core') 3 | 4 | const PULL = { 5 | key: 'value' 6 | } 7 | 8 | const CONTEXT_PULL = { 9 | eventName: 'pull_request', 10 | payload: { 11 | pull_request: PULL 12 | } 13 | } 14 | 15 | const CONTEXT_PUSH = { 16 | eventName: 'push', 17 | payload: {} 18 | } 19 | 20 | let warnSpy; 21 | 22 | describe('the context-extractor', () => { 23 | 24 | afterAll(() => { 25 | jest.restoreAllMocks() 26 | }) 27 | 28 | beforeEach(() => { 29 | jest.clearAllMocks() 30 | 31 | warnSpy = jest.spyOn(core, 'warning').mockImplementation(jest.fn()) 32 | }) 33 | 34 | it('will return the pull request context', () => { 35 | const pull = contextExtractor.getPullRequestContext(CONTEXT_PULL) 36 | 37 | expect(pull).toBe(PULL) 38 | }) 39 | 40 | it('will error if not pull request context', () => { 41 | const cntxt = contextExtractor.getPullRequestContext(CONTEXT_PUSH) 42 | 43 | expect(cntxt).toBe(undefined) 44 | expect(warnSpy.mock.calls.length).toBe(1) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /__tests__/env.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const eventPath = path.resolve(__dirname, 'test_pull_request.json') 3 | process.env.GITHUB_EVENT_PATH = eventPath 4 | -------------------------------------------------------------------------------- /__tests__/label-extractor.test.js: -------------------------------------------------------------------------------- 1 | const labelExtractor = require('../src/label-extractor') 2 | 3 | const EXPECTED_LABELS = ['label-1','label-2_with_underscore','special-[characters](please)'] 4 | const EXPECTED_LABELS_SPACES = ['label 1','label 2_with_underscore','special [characters] (please)'] 5 | const EXPECTED_SINGLE_ENTRY = ['no changelog entry needed'] 6 | 7 | describe('the label-extractor', () => { 8 | 9 | it('should return all labels', () => { 10 | const labels = labelExtractor.extractLabels('label-1,label-2_with_underscore,special-[characters](please)') 11 | 12 | expect(labels).toStrictEqual(EXPECTED_LABELS) 13 | }) 14 | 15 | it('should return all labels when spaces are included and trailing comma', () => { 16 | const labels = labelExtractor.extractLabels('label-1 , label-2_with_underscore , special-[characters](please),') 17 | 18 | expect(labels).toStrictEqual(EXPECTED_LABELS) 19 | }) 20 | 21 | it('should return all labels with spaces', () => { 22 | const labels = labelExtractor.extractLabels('label 1,label 2_with_underscore,special [characters] (please)') 23 | 24 | expect(labels).toStrictEqual(EXPECTED_LABELS_SPACES) 25 | }) 26 | 27 | it('should return only a single labels with spaces', () => { 28 | const labels = labelExtractor.extractLabels('no changelog entry needed') 29 | 30 | expect(labels).toStrictEqual(EXPECTED_SINGLE_ENTRY) 31 | }) 32 | 33 | it('should handle labels containing a forward slash', () => { 34 | const labels = labelExtractor.extractLabels('skip/changelog') 35 | 36 | expect(labels).toStrictEqual(['skip/changelog']) 37 | }) 38 | 39 | it('should handle multiple labels containing a forward slash', () => { 40 | const labels = labelExtractor.extractLabels('skip/changelog,no/changelog') 41 | 42 | expect(labels).toStrictEqual(['skip/changelog', 'no/changelog']) 43 | }) 44 | 45 | 46 | it('should handle multiple labels containing a `:` characters (emoji usage)', () => { 47 | const labels = labelExtractor.extractLabels(':wrench: GitHub Actions, :smile: Best Label Ever') 48 | 49 | expect(labels).toStrictEqual([':wrench: GitHub Actions', ':smile: Best Label Ever']) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /__tests__/test_pull_request.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "opened", 3 | "number": 2, 4 | "pull_request": { 5 | "url": "https://api.github.com/repos/Codertocat/Hello-World/pulls/2", 6 | "id": 279147437, 7 | "node_id": "MDExOlB1bGxSZXF1ZXN0Mjc5MTQ3NDM3", 8 | "html_url": "https://github.com/Codertocat/Hello-World/pull/2", 9 | "diff_url": "https://github.com/Codertocat/Hello-World/pull/2.diff", 10 | "patch_url": "https://github.com/Codertocat/Hello-World/pull/2.patch", 11 | "issue_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/2", 12 | "number": 2, 13 | "state": "open", 14 | "locked": false, 15 | "title": "Update the README with new information.", 16 | "user": { 17 | "login": "Codertocat", 18 | "id": 21031067, 19 | "node_id": "MDQ6VXNlcjIxMDMxMDY3", 20 | "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", 21 | "gravatar_id": "", 22 | "url": "https://api.github.com/users/Codertocat", 23 | "html_url": "https://github.com/Codertocat", 24 | "followers_url": "https://api.github.com/users/Codertocat/followers", 25 | "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", 26 | "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", 27 | "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", 28 | "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", 29 | "organizations_url": "https://api.github.com/users/Codertocat/orgs", 30 | "repos_url": "https://api.github.com/users/Codertocat/repos", 31 | "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", 32 | "received_events_url": "https://api.github.com/users/Codertocat/received_events", 33 | "type": "User", 34 | "site_admin": false 35 | }, 36 | "body": "This is a pretty simple change that we need to pull into master.", 37 | "created_at": "2019-05-15T15:20:33Z", 38 | "updated_at": "2019-05-15T15:20:33Z", 39 | "closed_at": null, 40 | "merged_at": null, 41 | "merge_commit_sha": null, 42 | "assignee": null, 43 | "assignees": [ 44 | 45 | ], 46 | "requested_reviewers": [ 47 | 48 | ], 49 | "requested_teams": [ 50 | 51 | ], 52 | "labels": [ 53 | { "name": "Skip-Changelog" }, 54 | { "name": "bug" } 55 | ], 56 | "milestone": null, 57 | "commits_url": "https://api.github.com/repos/Codertocat/Hello-World/pulls/2/commits", 58 | "review_comments_url": "https://api.github.com/repos/Codertocat/Hello-World/pulls/2/comments", 59 | "review_comment_url": "https://api.github.com/repos/Codertocat/Hello-World/pulls/comments{/number}", 60 | "comments_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/2/comments", 61 | "statuses_url": "https://api.github.com/repos/Codertocat/Hello-World/statuses/ec26c3e57ca3a959ca5aad62de7213c562f8c821", 62 | "head": { 63 | "label": "Codertocat:changes", 64 | "ref": "changes", 65 | "sha": "ec26c3e57ca3a959ca5aad62de7213c562f8c821", 66 | "user": { 67 | "login": "Codertocat", 68 | "id": 21031067, 69 | "node_id": "MDQ6VXNlcjIxMDMxMDY3", 70 | "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", 71 | "gravatar_id": "", 72 | "url": "https://api.github.com/users/Codertocat", 73 | "html_url": "https://github.com/Codertocat", 74 | "followers_url": "https://api.github.com/users/Codertocat/followers", 75 | "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", 76 | "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", 77 | "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", 78 | "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", 79 | "organizations_url": "https://api.github.com/users/Codertocat/orgs", 80 | "repos_url": "https://api.github.com/users/Codertocat/repos", 81 | "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", 82 | "received_events_url": "https://api.github.com/users/Codertocat/received_events", 83 | "type": "User", 84 | "site_admin": false 85 | }, 86 | "repo": { 87 | "id": 186853002, 88 | "node_id": "MDEwOlJlcG9zaXRvcnkxODY4NTMwMDI=", 89 | "name": "Hello-World", 90 | "full_name": "Codertocat/Hello-World", 91 | "private": false, 92 | "owner": { 93 | "login": "Codertocat", 94 | "id": 21031067, 95 | "node_id": "MDQ6VXNlcjIxMDMxMDY3", 96 | "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", 97 | "gravatar_id": "", 98 | "url": "https://api.github.com/users/Codertocat", 99 | "html_url": "https://github.com/Codertocat", 100 | "followers_url": "https://api.github.com/users/Codertocat/followers", 101 | "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", 102 | "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", 103 | "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", 104 | "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", 105 | "organizations_url": "https://api.github.com/users/Codertocat/orgs", 106 | "repos_url": "https://api.github.com/users/Codertocat/repos", 107 | "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", 108 | "received_events_url": "https://api.github.com/users/Codertocat/received_events", 109 | "type": "User", 110 | "site_admin": false 111 | }, 112 | "html_url": "https://github.com/Codertocat/Hello-World", 113 | "description": null, 114 | "fork": false, 115 | "url": "https://api.github.com/repos/Codertocat/Hello-World", 116 | "forks_url": "https://api.github.com/repos/Codertocat/Hello-World/forks", 117 | "keys_url": "https://api.github.com/repos/Codertocat/Hello-World/keys{/key_id}", 118 | "collaborators_url": "https://api.github.com/repos/Codertocat/Hello-World/collaborators{/collaborator}", 119 | "teams_url": "https://api.github.com/repos/Codertocat/Hello-World/teams", 120 | "hooks_url": "https://api.github.com/repos/Codertocat/Hello-World/hooks", 121 | "issue_events_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/events{/number}", 122 | "events_url": "https://api.github.com/repos/Codertocat/Hello-World/events", 123 | "assignees_url": "https://api.github.com/repos/Codertocat/Hello-World/assignees{/user}", 124 | "branches_url": "https://api.github.com/repos/Codertocat/Hello-World/branches{/branch}", 125 | "tags_url": "https://api.github.com/repos/Codertocat/Hello-World/tags", 126 | "blobs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/blobs{/sha}", 127 | "git_tags_url": "https://api.github.com/repos/Codertocat/Hello-World/git/tags{/sha}", 128 | "git_refs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/refs{/sha}", 129 | "trees_url": "https://api.github.com/repos/Codertocat/Hello-World/git/trees{/sha}", 130 | "statuses_url": "https://api.github.com/repos/Codertocat/Hello-World/statuses/{sha}", 131 | "languages_url": "https://api.github.com/repos/Codertocat/Hello-World/languages", 132 | "stargazers_url": "https://api.github.com/repos/Codertocat/Hello-World/stargazers", 133 | "contributors_url": "https://api.github.com/repos/Codertocat/Hello-World/contributors", 134 | "subscribers_url": "https://api.github.com/repos/Codertocat/Hello-World/subscribers", 135 | "subscription_url": "https://api.github.com/repos/Codertocat/Hello-World/subscription", 136 | "commits_url": "https://api.github.com/repos/Codertocat/Hello-World/commits{/sha}", 137 | "git_commits_url": "https://api.github.com/repos/Codertocat/Hello-World/git/commits{/sha}", 138 | "comments_url": "https://api.github.com/repos/Codertocat/Hello-World/comments{/number}", 139 | "issue_comment_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/comments{/number}", 140 | "contents_url": "https://api.github.com/repos/Codertocat/Hello-World/contents/{+path}", 141 | "compare_url": "https://api.github.com/repos/Codertocat/Hello-World/compare/{base}...{head}", 142 | "merges_url": "https://api.github.com/repos/Codertocat/Hello-World/merges", 143 | "archive_url": "https://api.github.com/repos/Codertocat/Hello-World/{archive_format}{/ref}", 144 | "downloads_url": "https://api.github.com/repos/Codertocat/Hello-World/downloads", 145 | "issues_url": "https://api.github.com/repos/Codertocat/Hello-World/issues{/number}", 146 | "pulls_url": "https://api.github.com/repos/Codertocat/Hello-World/pulls{/number}", 147 | "milestones_url": "https://api.github.com/repos/Codertocat/Hello-World/milestones{/number}", 148 | "notifications_url": "https://api.github.com/repos/Codertocat/Hello-World/notifications{?since,all,participating}", 149 | "labels_url": "https://api.github.com/repos/Codertocat/Hello-World/labels{/name}", 150 | "releases_url": "https://api.github.com/repos/Codertocat/Hello-World/releases{/id}", 151 | "deployments_url": "https://api.github.com/repos/Codertocat/Hello-World/deployments", 152 | "created_at": "2019-05-15T15:19:25Z", 153 | "updated_at": "2019-05-15T15:19:27Z", 154 | "pushed_at": "2019-05-15T15:20:32Z", 155 | "git_url": "git://github.com/Codertocat/Hello-World.git", 156 | "ssh_url": "git@github.com:Codertocat/Hello-World.git", 157 | "clone_url": "https://github.com/Codertocat/Hello-World.git", 158 | "svn_url": "https://github.com/Codertocat/Hello-World", 159 | "homepage": null, 160 | "size": 0, 161 | "stargazers_count": 0, 162 | "watchers_count": 0, 163 | "language": null, 164 | "has_issues": true, 165 | "has_projects": true, 166 | "has_downloads": true, 167 | "has_wiki": true, 168 | "has_pages": true, 169 | "forks_count": 0, 170 | "mirror_url": null, 171 | "archived": false, 172 | "disabled": false, 173 | "open_issues_count": 2, 174 | "license": null, 175 | "forks": 0, 176 | "open_issues": 2, 177 | "watchers": 0, 178 | "default_branch": "master" 179 | } 180 | }, 181 | "base": { 182 | "label": "Codertocat:master", 183 | "ref": "master", 184 | "sha": "f95f852bd8fca8fcc58a9a2d6c842781e32a215e", 185 | "user": { 186 | "login": "Codertocat", 187 | "id": 21031067, 188 | "node_id": "MDQ6VXNlcjIxMDMxMDY3", 189 | "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", 190 | "gravatar_id": "", 191 | "url": "https://api.github.com/users/Codertocat", 192 | "html_url": "https://github.com/Codertocat", 193 | "followers_url": "https://api.github.com/users/Codertocat/followers", 194 | "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", 195 | "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", 196 | "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", 197 | "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", 198 | "organizations_url": "https://api.github.com/users/Codertocat/orgs", 199 | "repos_url": "https://api.github.com/users/Codertocat/repos", 200 | "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", 201 | "received_events_url": "https://api.github.com/users/Codertocat/received_events", 202 | "type": "User", 203 | "site_admin": false 204 | }, 205 | "repo": { 206 | "id": 186853002, 207 | "node_id": "MDEwOlJlcG9zaXRvcnkxODY4NTMwMDI=", 208 | "name": "Hello-World", 209 | "full_name": "Codertocat/Hello-World", 210 | "private": false, 211 | "owner": { 212 | "login": "Codertocat", 213 | "id": 21031067, 214 | "node_id": "MDQ6VXNlcjIxMDMxMDY3", 215 | "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", 216 | "gravatar_id": "", 217 | "url": "https://api.github.com/users/Codertocat", 218 | "html_url": "https://github.com/Codertocat", 219 | "followers_url": "https://api.github.com/users/Codertocat/followers", 220 | "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", 221 | "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", 222 | "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", 223 | "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", 224 | "organizations_url": "https://api.github.com/users/Codertocat/orgs", 225 | "repos_url": "https://api.github.com/users/Codertocat/repos", 226 | "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", 227 | "received_events_url": "https://api.github.com/users/Codertocat/received_events", 228 | "type": "User", 229 | "site_admin": false 230 | }, 231 | "html_url": "https://github.com/Codertocat/Hello-World", 232 | "description": null, 233 | "fork": false, 234 | "url": "https://api.github.com/repos/Codertocat/Hello-World", 235 | "forks_url": "https://api.github.com/repos/Codertocat/Hello-World/forks", 236 | "keys_url": "https://api.github.com/repos/Codertocat/Hello-World/keys{/key_id}", 237 | "collaborators_url": "https://api.github.com/repos/Codertocat/Hello-World/collaborators{/collaborator}", 238 | "teams_url": "https://api.github.com/repos/Codertocat/Hello-World/teams", 239 | "hooks_url": "https://api.github.com/repos/Codertocat/Hello-World/hooks", 240 | "issue_events_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/events{/number}", 241 | "events_url": "https://api.github.com/repos/Codertocat/Hello-World/events", 242 | "assignees_url": "https://api.github.com/repos/Codertocat/Hello-World/assignees{/user}", 243 | "branches_url": "https://api.github.com/repos/Codertocat/Hello-World/branches{/branch}", 244 | "tags_url": "https://api.github.com/repos/Codertocat/Hello-World/tags", 245 | "blobs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/blobs{/sha}", 246 | "git_tags_url": "https://api.github.com/repos/Codertocat/Hello-World/git/tags{/sha}", 247 | "git_refs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/refs{/sha}", 248 | "trees_url": "https://api.github.com/repos/Codertocat/Hello-World/git/trees{/sha}", 249 | "statuses_url": "https://api.github.com/repos/Codertocat/Hello-World/statuses/{sha}", 250 | "languages_url": "https://api.github.com/repos/Codertocat/Hello-World/languages", 251 | "stargazers_url": "https://api.github.com/repos/Codertocat/Hello-World/stargazers", 252 | "contributors_url": "https://api.github.com/repos/Codertocat/Hello-World/contributors", 253 | "subscribers_url": "https://api.github.com/repos/Codertocat/Hello-World/subscribers", 254 | "subscription_url": "https://api.github.com/repos/Codertocat/Hello-World/subscription", 255 | "commits_url": "https://api.github.com/repos/Codertocat/Hello-World/commits{/sha}", 256 | "git_commits_url": "https://api.github.com/repos/Codertocat/Hello-World/git/commits{/sha}", 257 | "comments_url": "https://api.github.com/repos/Codertocat/Hello-World/comments{/number}", 258 | "issue_comment_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/comments{/number}", 259 | "contents_url": "https://api.github.com/repos/Codertocat/Hello-World/contents/{+path}", 260 | "compare_url": "https://api.github.com/repos/Codertocat/Hello-World/compare/{base}...{head}", 261 | "merges_url": "https://api.github.com/repos/Codertocat/Hello-World/merges", 262 | "archive_url": "https://api.github.com/repos/Codertocat/Hello-World/{archive_format}{/ref}", 263 | "downloads_url": "https://api.github.com/repos/Codertocat/Hello-World/downloads", 264 | "issues_url": "https://api.github.com/repos/Codertocat/Hello-World/issues{/number}", 265 | "pulls_url": "https://api.github.com/repos/Codertocat/Hello-World/pulls{/number}", 266 | "milestones_url": "https://api.github.com/repos/Codertocat/Hello-World/milestones{/number}", 267 | "notifications_url": "https://api.github.com/repos/Codertocat/Hello-World/notifications{?since,all,participating}", 268 | "labels_url": "https://api.github.com/repos/Codertocat/Hello-World/labels{/name}", 269 | "releases_url": "https://api.github.com/repos/Codertocat/Hello-World/releases{/id}", 270 | "deployments_url": "https://api.github.com/repos/Codertocat/Hello-World/deployments", 271 | "created_at": "2019-05-15T15:19:25Z", 272 | "updated_at": "2019-05-15T15:19:27Z", 273 | "pushed_at": "2019-05-15T15:20:32Z", 274 | "git_url": "git://github.com/Codertocat/Hello-World.git", 275 | "ssh_url": "git@github.com:Codertocat/Hello-World.git", 276 | "clone_url": "https://github.com/Codertocat/Hello-World.git", 277 | "svn_url": "https://github.com/Codertocat/Hello-World", 278 | "homepage": null, 279 | "size": 0, 280 | "stargazers_count": 0, 281 | "watchers_count": 0, 282 | "language": null, 283 | "has_issues": true, 284 | "has_projects": true, 285 | "has_downloads": true, 286 | "has_wiki": true, 287 | "has_pages": true, 288 | "forks_count": 0, 289 | "mirror_url": null, 290 | "archived": false, 291 | "disabled": false, 292 | "open_issues_count": 2, 293 | "license": null, 294 | "forks": 0, 295 | "open_issues": 2, 296 | "watchers": 0, 297 | "default_branch": "master" 298 | } 299 | }, 300 | "_links": { 301 | "self": { 302 | "href": "https://api.github.com/repos/Codertocat/Hello-World/pulls/2" 303 | }, 304 | "html": { 305 | "href": "https://github.com/Codertocat/Hello-World/pull/2" 306 | }, 307 | "issue": { 308 | "href": "https://api.github.com/repos/Codertocat/Hello-World/issues/2" 309 | }, 310 | "comments": { 311 | "href": "https://api.github.com/repos/Codertocat/Hello-World/issues/2/comments" 312 | }, 313 | "review_comments": { 314 | "href": "https://api.github.com/repos/Codertocat/Hello-World/pulls/2/comments" 315 | }, 316 | "review_comment": { 317 | "href": "https://api.github.com/repos/Codertocat/Hello-World/pulls/comments{/number}" 318 | }, 319 | "commits": { 320 | "href": "https://api.github.com/repos/Codertocat/Hello-World/pulls/2/commits" 321 | }, 322 | "statuses": { 323 | "href": "https://api.github.com/repos/Codertocat/Hello-World/statuses/ec26c3e57ca3a959ca5aad62de7213c562f8c821" 324 | } 325 | }, 326 | "author_association": "OWNER", 327 | "draft": false, 328 | "merged": false, 329 | "mergeable": null, 330 | "rebaseable": null, 331 | "mergeable_state": "unknown", 332 | "merged_by": null, 333 | "comments": 0, 334 | "review_comments": 0, 335 | "maintainer_can_modify": false, 336 | "commits": 1, 337 | "additions": 1, 338 | "deletions": 1, 339 | "changed_files": 1 340 | }, 341 | "repository": { 342 | "id": 186853002, 343 | "node_id": "MDEwOlJlcG9zaXRvcnkxODY4NTMwMDI=", 344 | "name": "Hello-World", 345 | "full_name": "Codertocat/Hello-World", 346 | "private": false, 347 | "owner": { 348 | "login": "Codertocat", 349 | "id": 21031067, 350 | "node_id": "MDQ6VXNlcjIxMDMxMDY3", 351 | "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", 352 | "gravatar_id": "", 353 | "url": "https://api.github.com/users/Codertocat", 354 | "html_url": "https://github.com/Codertocat", 355 | "followers_url": "https://api.github.com/users/Codertocat/followers", 356 | "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", 357 | "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", 358 | "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", 359 | "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", 360 | "organizations_url": "https://api.github.com/users/Codertocat/orgs", 361 | "repos_url": "https://api.github.com/users/Codertocat/repos", 362 | "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", 363 | "received_events_url": "https://api.github.com/users/Codertocat/received_events", 364 | "type": "User", 365 | "site_admin": false 366 | }, 367 | "html_url": "https://github.com/Codertocat/Hello-World", 368 | "description": null, 369 | "fork": false, 370 | "url": "https://api.github.com/repos/Codertocat/Hello-World", 371 | "forks_url": "https://api.github.com/repos/Codertocat/Hello-World/forks", 372 | "keys_url": "https://api.github.com/repos/Codertocat/Hello-World/keys{/key_id}", 373 | "collaborators_url": "https://api.github.com/repos/Codertocat/Hello-World/collaborators{/collaborator}", 374 | "teams_url": "https://api.github.com/repos/Codertocat/Hello-World/teams", 375 | "hooks_url": "https://api.github.com/repos/Codertocat/Hello-World/hooks", 376 | "issue_events_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/events{/number}", 377 | "events_url": "https://api.github.com/repos/Codertocat/Hello-World/events", 378 | "assignees_url": "https://api.github.com/repos/Codertocat/Hello-World/assignees{/user}", 379 | "branches_url": "https://api.github.com/repos/Codertocat/Hello-World/branches{/branch}", 380 | "tags_url": "https://api.github.com/repos/Codertocat/Hello-World/tags", 381 | "blobs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/blobs{/sha}", 382 | "git_tags_url": "https://api.github.com/repos/Codertocat/Hello-World/git/tags{/sha}", 383 | "git_refs_url": "https://api.github.com/repos/Codertocat/Hello-World/git/refs{/sha}", 384 | "trees_url": "https://api.github.com/repos/Codertocat/Hello-World/git/trees{/sha}", 385 | "statuses_url": "https://api.github.com/repos/Codertocat/Hello-World/statuses/{sha}", 386 | "languages_url": "https://api.github.com/repos/Codertocat/Hello-World/languages", 387 | "stargazers_url": "https://api.github.com/repos/Codertocat/Hello-World/stargazers", 388 | "contributors_url": "https://api.github.com/repos/Codertocat/Hello-World/contributors", 389 | "subscribers_url": "https://api.github.com/repos/Codertocat/Hello-World/subscribers", 390 | "subscription_url": "https://api.github.com/repos/Codertocat/Hello-World/subscription", 391 | "commits_url": "https://api.github.com/repos/Codertocat/Hello-World/commits{/sha}", 392 | "git_commits_url": "https://api.github.com/repos/Codertocat/Hello-World/git/commits{/sha}", 393 | "comments_url": "https://api.github.com/repos/Codertocat/Hello-World/comments{/number}", 394 | "issue_comment_url": "https://api.github.com/repos/Codertocat/Hello-World/issues/comments{/number}", 395 | "contents_url": "https://api.github.com/repos/Codertocat/Hello-World/contents/{+path}", 396 | "compare_url": "https://api.github.com/repos/Codertocat/Hello-World/compare/{base}...{head}", 397 | "merges_url": "https://api.github.com/repos/Codertocat/Hello-World/merges", 398 | "archive_url": "https://api.github.com/repos/Codertocat/Hello-World/{archive_format}{/ref}", 399 | "downloads_url": "https://api.github.com/repos/Codertocat/Hello-World/downloads", 400 | "issues_url": "https://api.github.com/repos/Codertocat/Hello-World/issues{/number}", 401 | "pulls_url": "https://api.github.com/repos/Codertocat/Hello-World/pulls{/number}", 402 | "milestones_url": "https://api.github.com/repos/Codertocat/Hello-World/milestones{/number}", 403 | "notifications_url": "https://api.github.com/repos/Codertocat/Hello-World/notifications{?since,all,participating}", 404 | "labels_url": "https://api.github.com/repos/Codertocat/Hello-World/labels{/name}", 405 | "releases_url": "https://api.github.com/repos/Codertocat/Hello-World/releases{/id}", 406 | "deployments_url": "https://api.github.com/repos/Codertocat/Hello-World/deployments", 407 | "created_at": "2019-05-15T15:19:25Z", 408 | "updated_at": "2019-05-15T15:19:27Z", 409 | "pushed_at": "2019-05-15T15:20:32Z", 410 | "git_url": "git://github.com/Codertocat/Hello-World.git", 411 | "ssh_url": "git@github.com:Codertocat/Hello-World.git", 412 | "clone_url": "https://github.com/Codertocat/Hello-World.git", 413 | "svn_url": "https://github.com/Codertocat/Hello-World", 414 | "homepage": null, 415 | "size": 0, 416 | "stargazers_count": 0, 417 | "watchers_count": 0, 418 | "language": null, 419 | "has_issues": true, 420 | "has_projects": true, 421 | "has_downloads": true, 422 | "has_wiki": true, 423 | "has_pages": true, 424 | "forks_count": 0, 425 | "mirror_url": null, 426 | "archived": false, 427 | "disabled": false, 428 | "open_issues_count": 2, 429 | "license": null, 430 | "forks": 0, 431 | "open_issues": 2, 432 | "watchers": 0, 433 | "default_branch": "master" 434 | }, 435 | "sender": { 436 | "login": "Codertocat", 437 | "id": 21031067, 438 | "node_id": "MDQ6VXNlcjIxMDMxMDY3", 439 | "avatar_url": "https://avatars1.githubusercontent.com/u/21031067?v=4", 440 | "gravatar_id": "", 441 | "url": "https://api.github.com/users/Codertocat", 442 | "html_url": "https://github.com/Codertocat", 443 | "followers_url": "https://api.github.com/users/Codertocat/followers", 444 | "following_url": "https://api.github.com/users/Codertocat/following{/other_user}", 445 | "gists_url": "https://api.github.com/users/Codertocat/gists{/gist_id}", 446 | "starred_url": "https://api.github.com/users/Codertocat/starred{/owner}{/repo}", 447 | "subscriptions_url": "https://api.github.com/users/Codertocat/subscriptions", 448 | "organizations_url": "https://api.github.com/users/Codertocat/orgs", 449 | "repos_url": "https://api.github.com/users/Codertocat/repos", 450 | "events_url": "https://api.github.com/users/Codertocat/events{/privacy}", 451 | "received_events_url": "https://api.github.com/users/Codertocat/received_events", 452 | "type": "User", 453 | "site_admin": false 454 | } 455 | } -------------------------------------------------------------------------------- /__tests__/version-extractor.test.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const versionExtractor = require('../src/version-extractor') 3 | 4 | const KEEP_A_CHANGELOG = ` 5 | ## Unreleased 6 | 7 | ## [v1.10.0] 8 | - Some changes 9 | 10 | ## [v1.9.2] 11 | Fixed a bug 12 | ` 13 | 14 | const CUSTOM = ` 15 | * Unreleased 16 | 17 | * v1.2.0 18 | - Some changes 19 | ` 20 | const VERSION_PATTERN = "^## \\[?((v|V)?\\d*\\.\\d*\\.\\d*-?\\w*|unreleased|Unreleased|UNRELEASED)\\]?" 21 | const CUSTOM_VERSION_PATTERN = "^\\* ((v|V)?\\d*\\.\\d*\\.\\d*-?\\w*|Unreleased)" 22 | 23 | describe('the verstion-extractor', () => { 24 | 25 | it('should return all versions via keep a changelog format', () => { 26 | const versions = versionExtractor.getVersions(VERSION_PATTERN, KEEP_A_CHANGELOG) 27 | expect(versions).toStrictEqual(['Unreleased', 'v1.10.0', 'v1.9.2']) 28 | }) 29 | 30 | it('should return all versions via custom format', () => { 31 | const versions = versionExtractor.getVersions(CUSTOM_VERSION_PATTERN, CUSTOM) 32 | expect(versions).toStrictEqual(['Unreleased', 'v1.2.0']) 33 | }) 34 | 35 | }) -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Changelog Enforcer' 2 | author: '@dangoslen' 3 | description: 'Enforces a repository changelog to be kept up to date.' 4 | outputs: 5 | errorMessage: 6 | description: "A message containing the reason why the Changelog Enforcer failed." 7 | inputs: 8 | changeLogPath: 9 | description: 'Path to the changelog file relative to the repository' 10 | required: true 11 | default: 'CHANGELOG.md' 12 | skipLabels: 13 | description: | 14 | "List of labels used to skip enforcing of the changelog during a pull request. Each label name is comma separated and only one label needs to be 15 | present for enforcement to be skipped. 16 | 17 | For example, if `label-1,label-2` was supplied as the `skipLabels`, `label-1` _or_ `label-2` would skip the enforcer. 18 | 19 | Each label is trimmed for leading and trailing spaces since GitHub labels do not allow for leading or trailing spaces. Thus, the following lists are equivalent: 20 | * `label-1,label-2` 21 | * `label-1 , label-2` 22 | * `label-1 ,label-2`" 23 | required: true 24 | default: 'Skip-Changelog' 25 | expectedLatestVersion: 26 | description: "The latest version of the software expected in the changelog. Should be in the form of 'v1.1.0' etc." 27 | required: true 28 | default: '' 29 | versionPattern: 30 | description: | 31 | "A regex pattern used to extract the version section headings from the changelog. Changelog Enforcer assumes the use of the [KeepAChangelog.com](https://keepachangelog.com/en/1.0.0/) convention for section headings, and as such looks for a line starting with `## [version] - date`. Versions are only extracted from the changelog when enforcing the expected latest version (via the `expectedLatestVersion` property). 32 | 33 | If you supply your own regex to match a different format, your regex must match the version string as a capture group (in the default format, that's the part inside square brackets). The first capture group will be used if your regex includes multiple groups. The regex pattern is used with global and multiline flags to find all of the versions in the changelog. 34 | 35 | Because the regex is passed as a `String` object, you will need to escape backslash characters (`\`) via `\\`." 36 | required: true 37 | default: "^## \\[((v|V)?\\d*\\.\\d*\\.\\d*-?\\w*|unreleased|Unreleased|UNRELEASED)\\]" 38 | missingUpdateErrorMessage: 39 | description: "The error message logged and returned in the 'errorMessage' output when no update to the changelog has been found." 40 | required: false 41 | token: 42 | description: "The secret value from your GITHUB_TOKEN or another token to access the GitHub API. Defaults to the token at `github.token`" 43 | required: true 44 | default: ${{ github.token }} 45 | runs: 46 | using: 'node20' 47 | main: 'dist/index.js' 48 | branding: 49 | icon: 'check-square' 50 | color: 'orange' 51 | -------------------------------------------------------------------------------- /bin/cut-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | VERSION=$1 4 | 5 | # Bump package.json 6 | jq --arg VERSION $VERSION '.version = $VERSION' package.json > updated.json && mv updated.json package.json 7 | 8 | # Install latest deps 9 | npm install 10 | 11 | # Run all tests and package the dist/* 12 | npm run all -------------------------------------------------------------------------------- /coverage/badge.svg: -------------------------------------------------------------------------------- 1 | Coverage: 97.14%Coverage97.14% -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import path from "node:path"; 3 | import { fileURLToPath } from "node:url"; 4 | import js from "@eslint/js"; 5 | import { FlatCompat } from "@eslint/eslintrc"; 6 | 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = path.dirname(__filename); 9 | const compat = new FlatCompat({ 10 | baseDirectory: __dirname, 11 | recommendedConfig: js.configs.recommended, 12 | allConfig: js.configs.all 13 | }); 14 | 15 | export default [...compat.extends("eslint:recommended"), { 16 | languageOptions: { 17 | globals: { 18 | ...globals.commonjs, 19 | ...globals.node, 20 | Atomics: "readonly", 21 | SharedArrayBuffer: "readonly", 22 | }, 23 | 24 | ecmaVersion: 2018, 25 | sourceType: "commonjs", 26 | }, 27 | 28 | rules: {}, 29 | }]; -------------------------------------------------------------------------------- /example-workflows/with-different-changelog-path.yaml: -------------------------------------------------------------------------------- 1 | ```yaml 2 | name: "Different Changelog Path" 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] 6 | 7 | jobs: 8 | # Enforces the update of a changelog file on every pull request 9 | changelog: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: dangoslen/changelog-enforcer@v2 14 | with: 15 | changeLogPath: 'different/changelog.md' 16 | ``` -------------------------------------------------------------------------------- /example-workflows/with-different-token.yaml: -------------------------------------------------------------------------------- 1 | ```yaml 2 | name: "Different Token" 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] 6 | 7 | jobs: 8 | # Enforces the update of a changelog file on every pull request 9 | changelog: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: dangoslen/changelog-enforcer@v2 14 | with: 15 | token: ${{ secrets. }} 16 | ``` -------------------------------------------------------------------------------- /example-workflows/with-expected-latest-version-custom-pattern.yaml: -------------------------------------------------------------------------------- 1 | ```yaml 2 | name: "Checking for Expected Latest Version and Custom Version Pattern" 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] 6 | 7 | jobs: 8 | # Enforces the update of a changelog file on every pull request 9 | changelog: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2.3.4 13 | 14 | - id: read-version 15 | run: | 16 | echo "::set-output name=version::$(jq -r ".version" package.json)" 17 | 18 | - id: changelog-enforcer 19 | uses: ./ 20 | with: 21 | skipLabels: "skip-changelog" 22 | # Anything within brackets that starts with `*` 23 | versionPattern: " ^\\* \\[(.*)\\]" 24 | expectedLatestVersion: ${{ steps.read-version.outputs.tag }} 25 | ``` -------------------------------------------------------------------------------- /example-workflows/with-expected-latest-version.yaml: -------------------------------------------------------------------------------- 1 | ```yaml 2 | name: "Checking for Expected Latest Version" 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] 6 | 7 | jobs: 8 | # Enforces the update of a changelog file on every pull request 9 | changelog: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2.3.4 13 | 14 | - id: read-version 15 | run: | 16 | echo "::set-output name=version::$(jq -r ".version" package.json)" 17 | 18 | - id: changelog-enforcer 19 | uses: ./ 20 | with: 21 | skipLabels: "skip-changelog" 22 | expectedLatestVersion: ${{ steps.read-version.outputs.tag }} 23 | ``` -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const changelogEnforcer = require('./src/changelog-enforcer') 2 | 3 | // Looks for a label with the name from 4 | async function run() { 5 | changelogEnforcer.enforce(); 6 | } 7 | 8 | run() 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFiles: ['./__tests__/env.js'], 3 | testMatch: ['**/*.test.js'], 4 | coverageReporters: ["json-summary"] 5 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "changelog-enforcer", 3 | "version": "3.6.1", 4 | "description": "Enforces that a changelog is kept up-to-date", 5 | "main": "index.js", 6 | "scripts": { 7 | "lint": "eslint index.js", 8 | "package": "ncc build index.js -o dist", 9 | "test": "eslint index.js && jest --coverage", 10 | "test:badges": "npm test && make-coverage-badge", 11 | "all": "npm run test:badges && npm run package" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/dangoslen/changelog-enforcer.git" 16 | }, 17 | "keywords": [ 18 | "GitHub", 19 | "Actions", 20 | "JavaScript", 21 | "Changelog" 22 | ], 23 | "author": "@dangoslen", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/dangoslen/changelog-enforcer/issues" 27 | }, 28 | "homepage": "https://github.com/dangoslen/changelog-enforcer#readme", 29 | "dependencies": { 30 | "@actions/core": "^1.10.1", 31 | "@actions/github": "^6.0.0", 32 | "make-coverage-badge": "^1.2.0", 33 | "node-fetch": "^2.7.0" 34 | }, 35 | "devDependencies": { 36 | "@eslint/eslintrc": "^3.1.0", 37 | "@eslint/js": "^9.7.0", 38 | "@vercel/ncc": "^0.38.1", 39 | "eslint": "^9.7.0", 40 | "globals": "^15.8.0", 41 | "jest": "^29.7.0", 42 | "y18n": "^5.0.8" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/changelog-enforcer.js: -------------------------------------------------------------------------------- 1 | const core = require('@actions/core') 2 | const github = require('@actions/github') 3 | const versionExtractor = require('./version-extractor') 4 | const labelExtractor = require('./label-extractor') 5 | const contextExtractor = require('./context-extractor') 6 | const { findChangelog, downloadChangelog } = require('./client') 7 | 8 | // Input keys 9 | const IN_CHANGELOG_PATH = 'changeLogPath' 10 | const IN_EXPECTED_LATEST_VERSION = 'expectedLatestVersion' 11 | const IN_VERSION_PATTERN = 'versionPattern' 12 | const IN_UPDATE_CUSTOM_ERROR = 'missingUpdateErrorMessage' 13 | const IN_SKIP_LABELS = 'skipLabels' 14 | const IN_TOKEN = "token" 15 | 16 | // Output keys 17 | const OUT_ERROR_MESSAGE = 'errorMessage' 18 | 19 | module.exports.enforce = async function () { 20 | try { 21 | const skipLabelList = getSkipLabels() 22 | const changeLogPath = core.getInput(IN_CHANGELOG_PATH) 23 | const missingUpdateErrorMessage = getMissingUpdateErrorMessage(changeLogPath) 24 | const expectedLatestVersion = core.getInput(IN_EXPECTED_LATEST_VERSION) 25 | const versionPattern = core.getInput(IN_VERSION_PATTERN) 26 | const token = getToken() 27 | 28 | core.info(`Skip Labels: ${skipLabelList}`) 29 | core.info(`Changelog Path: ${changeLogPath}`) 30 | core.info(`Missing Update Error Message: ${missingUpdateErrorMessage}`) 31 | core.info(`Expected Latest Version: ${expectedLatestVersion}`) 32 | core.info(`Version Pattern: ${versionPattern}`) 33 | 34 | const context = github.context 35 | const pullRequest = contextExtractor.getPullRequestContext(context) 36 | if (!pullRequest) { 37 | return 38 | } 39 | 40 | const repository = `${context.repo.owner}/${context.repo.repo}` 41 | const labelNames = pullRequest.labels.map(l => l.name) 42 | if (!shouldEnforceChangelog(labelNames, skipLabelList)) { 43 | return 44 | } 45 | const changelog = await checkChangeLog(token, repository, pullRequest.number, changeLogPath, missingUpdateErrorMessage) 46 | if (shouldEnforceVersion(expectedLatestVersion)) { 47 | return 48 | } 49 | await validateLatestVersion(token, expectedLatestVersion, versionPattern, changelog.contents_url) 50 | } catch (err) { 51 | core.setOutput(OUT_ERROR_MESSAGE, err.message) 52 | core.setFailed(err.message) 53 | } 54 | }; 55 | 56 | function getSkipLabels() { 57 | const skipLabels = core.getInput(IN_SKIP_LABELS) 58 | return labelExtractor.extractLabels(skipLabels) 59 | } 60 | 61 | function getMissingUpdateErrorMessage(changeLogPath) { 62 | const customMessage = core.getInput(IN_UPDATE_CUSTOM_ERROR) 63 | if (customMessage != null && customMessage != '') { 64 | return customMessage 65 | } 66 | return `No update to ${changeLogPath} found!` 67 | } 68 | 69 | function getToken() { 70 | const token = core.getInput(IN_TOKEN) 71 | if (!token) { 72 | throw new Error("Did not find token for using the GitHub API") 73 | } 74 | return token 75 | } 76 | 77 | function shouldEnforceChangelog(labelNames, skipLabelList) { 78 | return !labelNames.some(l => skipLabelList.includes(l)) 79 | } 80 | 81 | function shouldEnforceVersion(expectedLatestVersion) { 82 | return expectedLatestVersion === '' 83 | } 84 | 85 | function normalizeChangelogPath(changeLogPath) { 86 | if (changeLogPath.startsWith('./')) { 87 | return changeLogPath.substring(2) 88 | } 89 | return changeLogPath 90 | } 91 | 92 | async function checkChangeLog(token, repository, pullRequestNumber, changeLogPath, missingUpdateErrorMessage) { 93 | const normalizedChangeLogPath = normalizeChangelogPath(changeLogPath) 94 | const changelog = await findChangelog(token, repository, pullRequestNumber, 100, normalizedChangeLogPath) 95 | if (!changelog) { 96 | throw new Error(missingUpdateErrorMessage) 97 | } 98 | return changelog 99 | } 100 | 101 | async function validateLatestVersion(token, expectedLatestVersion, versionPattern, changelogUrl) { 102 | const changelog = await downloadChangelog(token, changelogUrl) 103 | const versions = versionExtractor.getVersions(versionPattern, changelog) 104 | let latest = versions[0] 105 | core.debug(`Latest version is ${latest}`) 106 | if (latest.toUpperCase() == "UNRELEASED") { 107 | if (versions.length == 1) { 108 | core.debug('There is only on unreleased version found in the changelog. Not validating expected version.') 109 | return 110 | } 111 | latest = versions[1] 112 | } 113 | if (latest !== expectedLatestVersion) { 114 | throw new Error(`The latest version in the changelog does not match the expected latest version of ${expectedLatestVersion}!`) 115 | } 116 | } -------------------------------------------------------------------------------- /src/client.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch') 2 | const core = require('@actions/core') 3 | 4 | module.exports.findChangelog = async function (token, repository, pullRequestNumber, pageSize, changeLogPath) { 5 | let complete = false; 6 | let page = 1 7 | while (!complete) { 8 | core.debug(`Downloading page ${page} of pull request files from /repos/${repository}/pulls/${pullRequestNumber}/files`) 9 | const options = addAuth(token, {}) 10 | const response = await fetch(`https://api.github.com/repos/${repository}/pulls/${pullRequestNumber}/files?per_page=${pageSize}&page=${page}`, options) 11 | if (!response.ok) { 12 | throw new Error(`Got a ${response.status} response from GitHub API`) 13 | } 14 | const files = await response.json() 15 | core.debug(`Downloaded page ${page} of pull request files`) 16 | 17 | core.debug("Filtering for changelog") 18 | const filtered = files 19 | .filter(f => f.status !== 'deleted') 20 | .filter(f => f.filename === changeLogPath) 21 | 22 | if (filtered.length == 1) { 23 | return filtered[0] 24 | } else if (files.length < pageSize) { 25 | complete = true 26 | } else { 27 | page++ 28 | } 29 | } 30 | return undefined 31 | } 32 | 33 | module.exports.downloadChangelog = async function (token, changelogUrl) { 34 | core.debug(`Downloading changelog from ${changelogUrl}`) 35 | const apiVersion = 'v3' 36 | const mediaTypeHeader = { 37 | 'headers': { 38 | 'Accept': `application/vnd.github.${apiVersion}.raw` 39 | } 40 | } 41 | const options = addAuth(token, mediaTypeHeader) 42 | const response = await fetch(`${changelogUrl}`, options) 43 | if (!response.ok) { 44 | throw new Error(`Got a ${response.status} response from GitHub API`) 45 | } 46 | const changelog = await response.text() 47 | core.debug("Downloaded changelog") 48 | return changelog 49 | } 50 | 51 | function addAuth(token, options) { 52 | const enriched = { ...options } 53 | if (!enriched['headers']) { 54 | enriched['headers'] = {} 55 | } 56 | enriched['headers']['Authorization'] = `Bearer ${token}` 57 | return enriched 58 | } -------------------------------------------------------------------------------- /src/context-extractor.js: -------------------------------------------------------------------------------- 1 | const core = require('@actions/core') 2 | 3 | module.exports.getPullRequestContext = function (context) { 4 | const pull_request = context.payload.pull_request 5 | if (pull_request == undefined) { 6 | core.warning(`ChangeLog enforcer only runs for pull_request and pull_request_target event types`) 7 | return undefined 8 | } 9 | return context.payload.pull_request; 10 | } -------------------------------------------------------------------------------- /src/label-extractor.js: -------------------------------------------------------------------------------- 1 | module.exports.extractLabels = function (labelsString) { 2 | // Parses a list of labels. Each label can be of any length and will either end with a comma or be the end of the string. 3 | // Matches 4 | // - words ("\w") 5 | // - whitespace characters ("\s") 6 | // - dashes ("-") 7 | // -plus signs ("+") 8 | // - questions marks ("?") 9 | // - semi-colons (";") 10 | // - colons (":") - this is for emoji usage 11 | // - brackets ("[" and "\]") 12 | // - parenthesis ("(" and ")") 13 | // - forward-slashes ("/") 14 | // Each match may are may not have a trailing comma (,?). If one exists, it is removed before appending it to the list 15 | const regex = new RegExp(/([\w\s-/+?;:[\]()]+,?)/, 'g') 16 | let labels = [] 17 | let groups 18 | do { 19 | groups = regex.exec(labelsString) 20 | if (groups) { 21 | // Removes the trailing comma and removes all whitespace 22 | let label = groups[0].replace(",", "").trim() 23 | labels.push(label) 24 | } 25 | } while(groups) 26 | return labels 27 | } 28 | -------------------------------------------------------------------------------- /src/version-extractor.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const core = require('@actions/core') 3 | 4 | module.exports.getVersions = function (pattern, changelog) { 5 | const regex = new RegExp(`${pattern}`, 'gm') 6 | let groups = false 7 | let versions = [] 8 | do { 9 | groups = regex.exec(changelog) 10 | if (groups) { 11 | // The actual group we want to match is the version 12 | core.debug(`Found version ${groups[1]}`) 13 | versions.push(groups[1]) 14 | } 15 | } while (groups) 16 | return versions 17 | } --------------------------------------------------------------------------------