├── .github ├── CONTRIBUTING.md ├── FUNDING.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .nvmrc ├── .prettierrc ├── LICENSE.md ├── README.md ├── bin └── cli.sh ├── jest.config.ts ├── jest.setup.ts ├── logo.png ├── package.json ├── pnpm-lock.yaml ├── release.config.json ├── src ├── Command.ts ├── commands │ ├── __test__ │ │ ├── notes.test.ts │ │ ├── publish.test.ts │ │ └── show.test.ts │ ├── notes.ts │ ├── publish.ts │ └── show.ts ├── index.ts ├── logger.ts └── utils │ ├── __test__ │ ├── createContext.test.ts │ ├── createReleaseComment.test.ts │ ├── execAsync.test.ts │ ├── formatDate.test.ts │ ├── getNextReleaseType.test.ts │ └── getNextVersion.test.ts │ ├── bumpPackageJson.ts │ ├── createContext.ts │ ├── createReleaseComment.ts │ ├── env.ts │ ├── execAsync.ts │ ├── formatDate.ts │ ├── getConfig.ts │ ├── getNextReleaseType.ts │ ├── getNextVersion.ts │ ├── git │ ├── __test__ │ │ ├── createTag.test.ts │ │ ├── getCommits.test.ts │ │ ├── getCurrentBranch.test.ts │ │ ├── getInfo.test.ts │ │ ├── getTags.test.ts │ │ └── parseCommits.test.ts │ ├── commit.ts │ ├── createTag.ts │ ├── getCommit.ts │ ├── getCommits.ts │ ├── getCurrentBranch.ts │ ├── getInfo.ts │ ├── getLatestRelease.ts │ ├── getTag.ts │ ├── getTags.ts │ ├── parseCommits.ts │ └── push.ts │ ├── github │ ├── __test__ │ │ ├── createGitHubRelease.test.ts │ │ ├── getCommitAuthors.test.ts │ │ └── validateAccessToken.test.ts │ ├── createComment.ts │ ├── createGitHubRelease.ts │ ├── getCommitAuthors.ts │ ├── getGitHubRelease.ts │ └── validateAccessToken.ts │ ├── readPackageJson.ts │ ├── release-notes │ ├── __test__ │ │ ├── getReleaseNotes.test.ts │ │ ├── getReleaseRefs.test.ts │ │ └── toMarkdown.test.ts │ ├── getReleaseNotes.ts │ ├── getReleaseRefs.ts │ └── toMarkdown.ts │ └── writePackageJson.ts ├── test ├── .env.test ├── env.ts ├── fixtures.ts └── utils.ts ├── tsconfig.build.json ├── tsconfig.json └── typings ├── git-log-parser.d.ts └── process.env.d.ts /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Please check https://github.com/ossjs/.github/blob/main/CONTRIBUTING.md which applies to all repos in our project. 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: kettanaito 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | 14 | - name: Setup Node 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: 18.14 18 | 19 | - uses: pnpm/action-setup@v2 20 | with: 21 | version: 7.12 22 | 23 | - name: Install dependencies 24 | run: pnpm install 25 | 26 | - name: Build 27 | run: pnpm build 28 | 29 | - name: Tests 30 | run: pnpm test 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | schedule: 5 | - cron: '0 23 * * * ' 6 | workflow_dispatch: 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | with: 15 | fetch-depth: 0 16 | token: ${{ secrets.CI_GITHUB_TOKEN }} 17 | 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 18.14 22 | always-auth: true 23 | registry-url: https://registry.npmjs.org 24 | 25 | - uses: pnpm/action-setup@v2 26 | with: 27 | version: 7.12 28 | 29 | - name: Setup Git 30 | run: | 31 | git config --local user.name "GitHub Actions" 32 | git config --local user.email "actions@github.com" 33 | 34 | - name: Install dependencies 35 | run: pnpm install 36 | 37 | - name: Build 38 | run: pnpm build 39 | 40 | - name: Release 41 | run: pnpm release 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.CI_GITHUB_TOKEN }} 44 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bin/** 3 | !bin/cli.sh 4 | .tmp -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.14.2 -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "arrowParens": "always" 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022–present Artem Zakharchenko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Release library logo 3 |

4 |

Release

5 | 6 |

Minimalistic, opinionated, and predictable release automation tool.

7 | 8 | ## General idea 9 | 10 | Think [Prettier](https://prettier.io/) but for automated releases: minimalistic, opinionated, and, most of all, predictable. This tool combines the usual expectations from a release manager but brings practicality to the table. 11 | 12 | Here's the publishing pipeline this tool implements: 13 | 14 | 1. Analyzes commits since the latest published release. 15 | 1. Determines next package version based on [Conventional Commits](https://www.conventionalcommits.org/) specification. 16 | 1. Runs the publishing script. 17 | 1. Creates release a tag and a release commit in Git. 18 | 1. Creates a new release on GitHub. 19 | 1. Pushes changes to GitHub. 20 | 1. Comments on relevant GitHub issues and pull requests. 21 | 22 | While this sounds like what any other release tool would do, the beauty lies in details. Let's take a more detailed look then at what this tool does differently. 23 | 24 | ### Defaults 25 | 26 | **The workflow above is the default (and the only) behavior.** 27 | 28 | That's the release process I personally want for all of my libraries, and that's why it's the default behavior for this tool. If you wish for the release automation tool to do something differently or skip certain steps, then this tool is not for you. I want a predictable, consistent release process, and that's largely achieved by the predictable release workflow for all my projects. 29 | 30 | ### Release commits 31 | 32 | **A release tag and a release commit are automatically created.** 33 | 34 | ``` 35 | commit cee5327f0c7fc9048de7a18ef7b5339acd648a98 (tag: v1.2.0) 36 | Author: GitHub Actions 37 | Date: Thu Apr 21 12:00:00 2022 +0100 38 | 39 | chore(release): v1.2.0 40 | 41 | ``` 42 | 43 | Release is a part of the project's history so it's crucial to have explicit release marker in Git presented by a release commit and a release tag. 44 | 45 | ### Respects publishing failures 46 | 47 | **If publishing fails, no release commits/tags will be created or pushed to Git.** 48 | 49 | Here's an average experience you'd have if your release (read "publishing to NPM") fails with an average release manager in the wild: 50 | 51 | 1. Process is terminated but the release tags/commits have already been created _and pushed_ to remote. 52 | 1. You need to manually revert the release commit. 53 | 1. You need to manually delete the release tag from everywhere. 54 | 1. You need to manually delete any other side-effects your release has (i.e. GitHub release). 55 | 56 | For an automated tooling there's sure a lot of the word "manual" in this scenario. The worst part is that you cannot just "retry" the release—you need to clean up all the artifacts the release manager has left you otherwise it'll treat the release as successful, stating there's nothing new to release. 57 | 58 | The bottom line is: failed releases happen. The package registry may be down, your publishing credentials may be wrong, or the entire internet may just decide to take a hiccup. The tooling you use should acknowledge that and support you in those failure cases, not leave you on your own to do manual cleanup chores after the automated solution. 59 | 60 | ## Opinionated behaviors 61 | 62 | - GitHub-only. This tool is designed for projects hosted on GitHub. 63 | - Release tag has the following format: `v${version}` (i.e. `v1.2.3`). 64 | - Release commit has the following format: `chore(release): v${version}`. 65 | - Does not generate or update the `CHANGELOG` file. This tool generates automatic release notes from your commits and creates a new GitHub release with those notes. Use GitHub releases instead of changelogs. 66 | 67 | ## Getting started 68 | 69 | ### Install 70 | 71 | ```sh 72 | npm install @ossjs/release --save-dev 73 | ``` 74 | 75 | ### Create configuration 76 | 77 | Create a `release.config.json` file at the root of your project. Open the newly created file and specify the `use` command that publishes your package: 78 | 79 | ```js 80 | // release.config.json 81 | { 82 | "profiles": [ 83 | { 84 | "name": "latest", 85 | "use": "npm publish" 86 | } 87 | ] 88 | } 89 | ``` 90 | 91 | ### Generate GitHub Personal Access Token 92 | 93 | Generate a [Personal Access Token](https://github.com/settings/tokens/new?scopes=repo,admin:repo_hook,admin:org_hook) for your GitHub user with the following permissions: 94 | 95 | - `repo` 96 | - `admin:repo_hook` 97 | - `admin:org_hook` 98 | 99 | Expose the generated access token in the `GITHUB_TOKEN` environmental variable in your local and/or CI environment. This tool uses the `GITHUB_TOKEN` variable to communicate with GitHub on your behalf: read and write releases, post comments on relevant issues, etc. 100 | 101 | ### Create a release 102 | 103 | Commit and push your changes following the [Conventional Commit](https://www.conventionalcommits.org/) message structure. Once done, run the following command to generate the next release automatically: 104 | 105 | ```sh 106 | release publish 107 | ``` 108 | 109 | Congratulations! :tada: You've successfully published your first release! 110 | 111 | ## Configuration 112 | 113 | This tool expects a configuration file at `release.config.json`. The configuration file must export an object of the following shape: 114 | 115 | ```ts 116 | { 117 | profiles: Array<{ 118 | /** 119 | * Profile name. 120 | * @default "latest" 121 | */ 122 | name: string 123 | 124 | /** 125 | * The publishing script to run. 126 | * @example "npm publish" 127 | * @example "pnpm publish --no-git-checks" 128 | */ 129 | use: string 130 | 131 | /** 132 | * Treat major version bumps as minor. 133 | * This prevents publishing a package that is in a 134 | * pre-release phase (< 1.0). 135 | */ 136 | prerelease?: boolean 137 | }> 138 | use: string 139 | } 140 | ``` 141 | 142 | ## API 143 | 144 | ### `publish` 145 | 146 | Publishes a new version of the package. 147 | 148 | #### Options 149 | 150 | | Option name | Type | Description | 151 | | ----------------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 152 | | `--profile`, `-p` | `string` (Default: `"latest"`) | Release profile name from `release.config.json`. | 153 | | `--dry-run`, `-d` | `boolean` | Creates a release in a dry-run mode. **Note:** this still requires a valid `GITHUB_TOKEN` environmental variable, as the dry-run mode will perform read operations on your repository. | 154 | 155 | #### Example 156 | 157 | Running this command will publish the package according to the `latest` defined profile: 158 | 159 | ```sh 160 | release publish 161 | ``` 162 | 163 | Providing an explicit `--profile` option allows to publish the package accordig to another profile from `release.config.json`: 164 | 165 | ```sh 166 | release publish --profile nightly 167 | ``` 168 | 169 | ### `notes` 170 | 171 | Generates release notes and creates a new GitHub release for the given release tag. 172 | 173 | This command is designed to recover from a partially failed release process, as well as to generate changelogs for old releases. 174 | 175 | - This command requires an existing (merged) release tag; 176 | - This command accepts past release tags; 177 | - This command has no effect if a GitHub release for the given tag already exists. 178 | 179 | #### Arguments 180 | 181 | | Argument name | Type | Description | 182 | | ------------- | -------- | ------------------------ | 183 | | `tag` | `string` | Tag name of the release. | 184 | 185 | #### Example 186 | 187 | ```sh 188 | # Generate release notes and create a GitHub release 189 | # for the release tag "v1.0.3". 190 | release notes v1.0.3 191 | ``` 192 | 193 | ### `show` 194 | 195 | Displays information about a particular release. 196 | 197 | Release information includes the following: 198 | 199 | - Commit associated with the release tag; 200 | - Release status (public/draft/unpublished); 201 | - GitHub release URL if present. 202 | 203 | #### Arguments 204 | 205 | | Argument name | Type | Description | 206 | | ------------- | -------- | --------------------------------------------- | 207 | | `tag` | `string` | (_Optional_) Tag name of the release to show. | 208 | 209 | #### Example 210 | 211 | ```sh 212 | # Display info about the latest release. 213 | release show 214 | ``` 215 | 216 | ```sh 217 | # Display info about a specific release. 218 | release show v0.19.2 219 | ``` 220 | 221 | ## Recipes 222 | 223 | This tool exposes a CLI which you can use with any continuous integration providers. No need to install actions, configure things, and pray for it to work. 224 | 225 | ```js 226 | { 227 | "name": "my-package", 228 | "scripts": { 229 | "release": "release publish" 230 | } 231 | } 232 | ``` 233 | 234 | ### GitHub Actions 235 | 236 | Before you proceed, make sure you've [generated GitHub Personal Access Token](#generate-github-personal-access-token). Create a [new repository/organization secret](https://docs.github.com/en/actions/security-guides/encrypted-secrets) called `CI_GITHUB_TOKEN` and use your Personal Access Token as the value for that secret. 237 | 238 | You will be using `secrets.CI_GITHUB_TOKEN` instead of the default `secrets.GITHUB_TOKEN` in the workflow file in order to have correct GitHub permissions during publishing. For example, your Personal Access Token will allow for Release to push release commits/tags to protected branches, while the default `secrets.GITHUB_TOKEN` will not. 239 | 240 | ```yml 241 | # .github/workflows/release.yml 242 | name: release 243 | on: 244 | push: 245 | branches: [main] 246 | jobs: 247 | release: 248 | runs-on: ubuntu-latest 249 | steps: 250 | - uses: actions/checkout@v3 251 | with: 252 | # Fetch the entire commit history to include all commits. 253 | # By default, "actions/checkout" checks out the repository 254 | # at the commit that's triggered the workflow. This means 255 | # that the "@ossjs/release" may not be able to read older 256 | # commits that may affect the next release version number. 257 | fetch-depth: 0 258 | 259 | # Provide your custom "CI_GITHUB_TOKEN" secret that holds 260 | # your GitHub Personal Access Token. 261 | token: ${{ secrets.CI_GITHUB_TOKEN }} 262 | 263 | - uses: actions/setup-node@v3 264 | with: 265 | always-auth: true 266 | registry-url: https://registry.npmjs.org 267 | 268 | # Configure the Git user that'd author release commits. 269 | - name: Setup Git 270 | run: | 271 | git config --local user.name "GitHub Actions" 272 | git config --local user.email "actions@github.com" 273 | 274 | - run: npm ci 275 | - run: npm test 276 | 277 | - run: npm run release 278 | env: 279 | # Set the "GITHUB_TOKEN" environmental variable 280 | # required by "@ossjs/release" to communicate with GitHub. 281 | GITHUB_TOKEN: ${{ secrets.CI_GITHUB_TOKEN }} 282 | 283 | # Set the "NODE_AUTH_TOKEN" environmental variable 284 | # that "actions/setup-node" uses as the "_authToken" 285 | # in the generated ".npmrc" file to authenticate 286 | # publishing to NPM registry. 287 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 288 | ``` 289 | 290 | Create the configuration file and specify the release script: 291 | 292 | ```js 293 | // release.config.json 294 | { 295 | "profiles": [ 296 | { 297 | "name": "latest", 298 | // Note that NPM doesn't need the next release version. 299 | // It will read the incremented version from "package.json". 300 | "use": "npm publish" 301 | } 302 | ] 303 | } 304 | ``` 305 | 306 | > If publishing a scoped package, use the `npm publish --access public` script instead. 307 | 308 | ### Usage with Yarn 309 | 310 | Running `yarn publish` will prompt you for the next release version. Use the `--new-version` option and provide it with the `RELEASE_VERSION` environmental variable injected by Release that indicates the next release version based on your commit history. 311 | 312 | ```js 313 | // release.config.json 314 | { 315 | "profiles": [ 316 | { 317 | "name": "latest", 318 | "use": "yarn publish --new-version $RELEASE_VERSION" 319 | } 320 | ] 321 | } 322 | ``` 323 | 324 | Yarn also doesn't seem to respect the `NODE_AUTH_TOKEN` environment variable. Please use the `NPM_AUTH_TOKEN` variable instead: 325 | 326 | ```yaml 327 | - run: yarn release 328 | env: 329 | GITHUB_TOKEN: ${{ secrets.CI_GITHUB_TOKEN }} 330 | 331 | # Use the "NPM_AUTH_TOKEN" instead of "NODE_AUTH_TOKEN". 332 | NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 333 | ``` 334 | 335 | ### Usage with PNPM 336 | 337 | ```js 338 | // release.config.json 339 | { 340 | "profiles": [ 341 | { 342 | "name": "latest", 343 | // Prevent PNPM from checking for a clean Git state 344 | // to ignore the intermediate release state of the repository. 345 | "use": "pnpm publish --no-git-checks" 346 | } 347 | ] 348 | } 349 | ``` 350 | 351 | ### Releasing multiple tags 352 | 353 | Leverage GitHub Actions and multiple Release configurations to release different tags from different Git branches. 354 | 355 | ```js 356 | // release.config.json 357 | { 358 | "profiles": [ 359 | { 360 | "name": "latest", 361 | "use": "npm publish" 362 | }, 363 | { 364 | "name": "nightly", 365 | "use": "npm publish --tag nightly" 366 | } 367 | ] 368 | } 369 | ``` 370 | 371 | ```yml 372 | name: release 373 | 374 | on: 375 | push: 376 | # Set multiple branches to trigger this workflow. 377 | branches: [main, dev] 378 | 379 | jobs: 380 | release: 381 | runs-on: ubuntu-latest 382 | steps: 383 | # Release to the default ("latest") tag on "main". 384 | - name: Release latest 385 | if: github.ref == "refs/heads/main" 386 | run: npx release publish 387 | 388 | # Release to the "nightly" tag on "dev'. 389 | - name: Release nightly 390 | if: github.ref == "refs/heads/dev" 391 | run: npx release publish -p nightly 392 | ``` 393 | 394 | ## Comparison 395 | 396 | Below you see how Release compares to other tools. Keep in mind that I'm only comparing how those tools work _by default_ because that's the only thing I care about. Unlike Release, other tools here can satisfy different use-cases through configuration, which is both a blessing and a curse. 397 | 398 | | | Release | Semantic Release | Auto | Changesets | 399 | | ----------------------------------------- | ------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- | 400 | | First-class citizen | CLI | Commit | Pull request (labels) | Changeset | 401 | | Derives next version from commits | ✅ | ✅ | ✅ | ❌ | 402 | | Creates a GitHub release | ✅ | [✅](https://github.com/semantic-release/semantic-release/releases/tag/v19.0.2) | [✅](https://github.com/intuit/auto/releases/tag/v10.36.5) | [✅](https://github.com/changesets/changesets/releases/tag/%40changesets%2Fgit%401.3.2) | 403 | | Creates a release commit in Git | ✅ | ❌ 1 | ✅ | ✅ | 404 | | Comments on relevant GitHub issues | ✅ | ✅ | [✅](https://github.com/intuit/auto/issues/1651#issuecomment-1073389235) | ❌ | 405 | | Comments on relevant GitHub pull requests | ✅ | [✅](https://github.com/semantic-release/semantic-release/pull/2330#issuecomment-1015001540) | [✅](https://github.com/intuit/auto/pull/2175#issuecomment-1073389222) | ? | 406 | | Reverts tags/commits if publishing fails | ✅ | ❌ | ? | ? | 407 | | Supports monorepos | ❌ | ✅ | ✅ | ✅ | 408 | | Supports dry run | ✅ | ✅ | ✅ | [❌](https://github.com/changesets/changesets/issues/614) | 409 | 410 | > 1 - requires additional plugins. 411 | -------------------------------------------------------------------------------- /bin/cli.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('./build/index.js') 3 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest' 2 | 3 | const config: Config = { 4 | roots: ['./src'], 5 | transform: { 6 | '^.+\\.ts$': '@swc/jest', 7 | }, 8 | setupFilesAfterEnv: ['./jest.setup.ts'], 9 | } 10 | 11 | export default config 12 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import { config } from 'dotenv' 3 | 4 | config({ 5 | path: path.resolve(__dirname, './test/.env.test'), 6 | }) 7 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ossjs/release/87bec2a3969832c032a3ca20cf18ade042252441/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ossjs/release", 3 | "version": "0.8.1", 4 | "description": "Minimalistic, opinionated, and predictable release automation tool.", 5 | "main": "./bin/build/index.js", 6 | "author": "Artem Zakharchenko ", 7 | "license": "MIT", 8 | "bin": { 9 | "release": "./bin/cli.sh" 10 | }, 11 | "files": [ 12 | "bin" 13 | ], 14 | "scripts": { 15 | "start": "pnpm build -- -w", 16 | "build": "tsc -p tsconfig.build.json", 17 | "test": "jest --runInBand", 18 | "prerelease": "pnpm build && pnpm test", 19 | "release": "./bin/cli.sh publish" 20 | }, 21 | "devDependencies": { 22 | "@swc/core": "^1.3.81", 23 | "@swc/jest": "^0.2.29", 24 | "@types/jest": "^29.5.4", 25 | "dotenv": "^16.3.1", 26 | "fs-teardown": "^0.3.2", 27 | "jest": "^29.6.4", 28 | "msw": "^1.2.5", 29 | "node-git-server": "^1.0.0-beta.30", 30 | "portfinder": "^1.0.28", 31 | "ts-node": "^10.7.0", 32 | "typescript": "^4.6.3" 33 | }, 34 | "dependencies": { 35 | "@open-draft/deferred-promise": "^2.1.0", 36 | "@open-draft/until": "^2.1.0", 37 | "@types/conventional-commits-parser": "^3.0.2", 38 | "@types/issue-parser": "^3.0.1", 39 | "@types/node": "^16.11.27", 40 | "@types/node-fetch": "2.x", 41 | "@types/rc": "^1.2.1", 42 | "@types/registry-auth-token": "^4.2.1", 43 | "@types/semver": "^7.5.1", 44 | "@types/yargs": "^17.0.10", 45 | "conventional-commits-parser": "^5.0.0", 46 | "get-stream": "^6.0.1", 47 | "git-log-parser": "^1.2.0", 48 | "issue-parser": "^6.0.0", 49 | "node-fetch": "2.6.7", 50 | "outvariant": "^1.4.0", 51 | "pino": "^7.10.0", 52 | "pino-pretty": "^7.6.1", 53 | "rc": "^1.2.8", 54 | "registry-auth-token": "^4.2.1", 55 | "semver": "^7.5.4", 56 | "yargs": "^17.7.2" 57 | }, 58 | "repository": { 59 | "type": "git", 60 | "url": "git+https://github.com/ossjs/release.git" 61 | }, 62 | "homepage": "https://github.com/ossjs/release#readme", 63 | "publishConfig": { 64 | "access": "public" 65 | }, 66 | "keywords": [ 67 | "release", 68 | "automation", 69 | "changelog", 70 | "pubilsh", 71 | "semver", 72 | "version", 73 | "package" 74 | ] 75 | } -------------------------------------------------------------------------------- /release.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": [ 3 | { 4 | "name": "latest", 5 | "use": "pnpm publish --no-git-checks", 6 | "prerelease": true 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /src/Command.ts: -------------------------------------------------------------------------------- 1 | import { log } from './logger' 2 | import type { BuilderCallback } from 'yargs' 3 | import type { Config } from './utils/getConfig' 4 | 5 | export interface DefaultArgv { 6 | _: (number | string)[] 7 | } 8 | 9 | export abstract class Command = {}> { 10 | static readonly command: string 11 | static readonly description: string 12 | static readonly builder: BuilderCallback<{}, any> = () => {} 13 | 14 | protected log: typeof log 15 | 16 | constructor( 17 | protected readonly config: Config, 18 | protected readonly argv: DefaultArgv & Argv, 19 | ) { 20 | this.log = log 21 | } 22 | 23 | public run = async (): Promise => {} 24 | } 25 | -------------------------------------------------------------------------------- /src/commands/__test__/notes.test.ts: -------------------------------------------------------------------------------- 1 | import { MockedRequest, ResponseResolver, rest, RestContext } from 'msw' 2 | import { Notes } from '../notes' 3 | import { log } from '../../logger' 4 | import { commit } from '../../utils/git/commit' 5 | import { testEnvironment } from '../../../test/env' 6 | import { execAsync } from '../../utils/execAsync' 7 | import { GitHubRelease } from '../../utils/github/getGitHubRelease' 8 | 9 | const { setup, reset, cleanup, api, createRepository } = testEnvironment({ 10 | fileSystemPath: 'notes', 11 | }) 12 | 13 | let gitHubReleaseHandler: jest.Mock = jest.fn< 14 | ReturnType, 15 | Parameters> 16 | >((req, res, ctx) => { 17 | return res( 18 | ctx.status(201), 19 | ctx.json({ 20 | html_url: '/releases/1', 21 | }), 22 | ) 23 | }) 24 | 25 | const githubLatestReleaseHandler = rest.get( 26 | `https://api.github.com/repos/:owner/:name/releases/latest`, 27 | (req, res, ctx) => { 28 | return res(ctx.status(404)) 29 | }, 30 | ) 31 | 32 | beforeAll(async () => { 33 | await setup() 34 | }) 35 | 36 | beforeEach(() => { 37 | api.use( 38 | rest.post( 39 | 'https://api.github.com/repos/:owner/:repo/releases', 40 | gitHubReleaseHandler, 41 | ), 42 | ) 43 | }) 44 | 45 | afterEach(async () => { 46 | await reset() 47 | }) 48 | 49 | afterAll(async () => { 50 | await cleanup() 51 | }) 52 | 53 | it('creates a GitHub release for a past release', async () => { 54 | await createRepository('past-release') 55 | 56 | api.use( 57 | githubLatestReleaseHandler, 58 | rest.get( 59 | 'https://api.github.com/repos/:owner/:repo/releases/tags/:tag', 60 | (req, res, ctx) => { 61 | return res(ctx.status(404)) 62 | }, 63 | ), 64 | ) 65 | 66 | // Preceding (previous) release. 67 | await commit({ 68 | message: `feat: long-ago published`, 69 | allowEmpty: true, 70 | }) 71 | const prevReleaseCommit = await commit({ 72 | message: `chore(release): v0.1.0`, 73 | allowEmpty: true, 74 | }) 75 | await execAsync('git tag v0.1.0') 76 | 77 | // Relevant release. 78 | const fixCommit = await commit({ 79 | message: `fix: relevant fix`, 80 | allowEmpty: true, 81 | }) 82 | await commit({ 83 | message: `docs: not worthy of release notes`, 84 | allowEmpty: true, 85 | }) 86 | const featCommit = await commit({ 87 | message: `feat: relevant feature`, 88 | allowEmpty: true, 89 | }) 90 | const releaseCommit = await commit({ 91 | message: `chore(release): v0.2.0`, 92 | allowEmpty: true, 93 | date: new Date('2005-04-07T22:13:13'), 94 | }) 95 | await execAsync(`git tag v0.2.0`) 96 | 97 | // Future release. 98 | await commit({ 99 | message: `fix: other that`, 100 | allowEmpty: true, 101 | }) 102 | await commit({ 103 | message: `chore(release): v0.2.1`, 104 | allowEmpty: true, 105 | }) 106 | await execAsync(`git tag v0.2.1`) 107 | 108 | const notes = new Notes( 109 | { 110 | profiles: [ 111 | { 112 | name: 'latest', 113 | use: 'exit 0', 114 | }, 115 | ], 116 | }, 117 | { 118 | _: ['', '0.2.0'], 119 | }, 120 | ) 121 | await notes.run() 122 | 123 | expect(log.info).toHaveBeenCalledWith( 124 | 'creating GitHub release for version "v0.2.0" in "octocat/past-release"...', 125 | ) 126 | 127 | expect(log.info).toHaveBeenCalledWith( 128 | `found release tag "v0.2.0" (${releaseCommit.hash})`, 129 | ) 130 | expect(log.info).toHaveBeenCalledWith( 131 | `found preceding release "v0.1.0" (${prevReleaseCommit.hash})`, 132 | ) 133 | 134 | // Must generate correct release notes. 135 | expect(log.info).toHaveBeenCalledWith(`generated release notes: 136 | ## v0.2.0 (2005-04-07) 137 | 138 | ### Features 139 | 140 | - relevant feature (${featCommit.hash}) 141 | 142 | ### Bug Fixes 143 | 144 | - relevant fix (${fixCommit.hash})`) 145 | 146 | // Must create a new GitHub release. 147 | expect(gitHubReleaseHandler).toHaveBeenCalledTimes(1) 148 | expect(log.info).toHaveBeenCalledWith('created GitHub release: /releases/1') 149 | }) 150 | 151 | it('skips creating a GitHub release if the given release already exists', async () => { 152 | await createRepository('skip-if-exists') 153 | 154 | api.use( 155 | githubLatestReleaseHandler, 156 | rest.get( 157 | 'https://api.github.com/repos/:owner/:repo/releases/tags/:tag', 158 | (req, res, ctx) => { 159 | return res( 160 | ctx.json({ 161 | tag_name: 'v1.0.0', 162 | html_url: '/releases/1', 163 | }), 164 | ) 165 | }, 166 | ), 167 | ) 168 | 169 | const notes = new Notes( 170 | { 171 | profiles: [ 172 | { 173 | name: 'latest', 174 | use: 'exit 0', 175 | }, 176 | ], 177 | }, 178 | { 179 | _: ['', '1.0.0'], 180 | }, 181 | ) 182 | await notes.run() 183 | 184 | expect(log.warn).toHaveBeenCalledWith( 185 | 'found existing GitHub release for "v1.0.0": /releases/1', 186 | ) 187 | expect(log.info).not.toHaveBeenCalledWith( 188 | 'creating GitHub release for version "v1.0.0" in "octocat/skip-if-exists"', 189 | ) 190 | expect(log.info).not.toHaveBeenCalledWith( 191 | expect.stringContaining('created GitHub release:'), 192 | ) 193 | 194 | expect(process.exit).toHaveBeenCalledWith(1) 195 | }) 196 | -------------------------------------------------------------------------------- /src/commands/__test__/publish.test.ts: -------------------------------------------------------------------------------- 1 | import * as fileSystem from 'fs' 2 | import { ResponseResolver, graphql, rest } from 'msw' 3 | import { log } from '../../logger' 4 | import { Publish } from '../publish' 5 | import type { GitHubRelease } from '../../utils/github/getGitHubRelease' 6 | import { testEnvironment } from '../../../test/env' 7 | import { execAsync } from '../../utils/execAsync' 8 | 9 | const { setup, reset, cleanup, api, createRepository } = testEnvironment({ 10 | fileSystemPath: './publish', 11 | }) 12 | 13 | beforeAll(async () => { 14 | await setup() 15 | }) 16 | 17 | afterEach(async () => { 18 | await reset() 19 | }) 20 | 21 | afterAll(async () => { 22 | await cleanup() 23 | }) 24 | 25 | const githubLatestReleaseHandler = rest.get( 26 | `https://api.github.com/repos/:owner/:name/releases/latest`, 27 | (req, res, ctx) => { 28 | return res(ctx.status(404)) 29 | }, 30 | ) 31 | 32 | it('publishes the next minor version', async () => { 33 | const repo = await createRepository('version-next-minor') 34 | 35 | api.use( 36 | graphql.query('GetCommitAuthors', (req, res, ctx) => { 37 | return res(ctx.data({})) 38 | }), 39 | githubLatestReleaseHandler, 40 | rest.post( 41 | 'https://api.github.com/repos/:owner/:repo/releases', 42 | (req, res, ctx) => { 43 | return res( 44 | ctx.status(201), 45 | ctx.json({ 46 | tag_name: 'v1.0.0', 47 | html_url: '/releases/1', 48 | }), 49 | ) 50 | }, 51 | ), 52 | ) 53 | 54 | await repo.fs.create({ 55 | 'package.json': JSON.stringify({ 56 | name: 'test', 57 | version: '0.0.0', 58 | }), 59 | }) 60 | await repo.fs.exec(`git add . && git commit -m 'feat: new things'`) 61 | 62 | const publish = new Publish( 63 | { 64 | profiles: [ 65 | { 66 | name: 'latest', 67 | use: 'echo "release script input: $RELEASE_VERSION"', 68 | }, 69 | ], 70 | }, 71 | { 72 | _: [], 73 | profile: 'latest', 74 | }, 75 | ) 76 | await publish.run() 77 | 78 | expect(log.error).not.toHaveBeenCalled() 79 | 80 | expect(log.info).toHaveBeenCalledWith( 81 | expect.stringContaining('found 2 new commits:'), 82 | ) 83 | 84 | // Must notify about the next version. 85 | expect(log.info).toHaveBeenCalledWith('release type "minor": 0.0.0 -> 0.1.0') 86 | 87 | // The release script is provided with the environmental variables. 88 | expect(process.stdout.write).toHaveBeenCalledWith( 89 | 'release script input: 0.1.0\n', 90 | ) 91 | expect(log.info).toHaveBeenCalledWith( 92 | expect.stringContaining('bumped version in package.json to: 0.1.0'), 93 | ) 94 | 95 | // Must bump the "version" in package.json. 96 | expect( 97 | JSON.parse(await repo.fs.readFile('package.json', 'utf8')), 98 | ).toHaveProperty('version', '0.1.0') 99 | 100 | expect(await repo.fs.exec('git log')).toHaveProperty( 101 | 'stdout', 102 | expect.stringContaining('chore(release): v0.1.0'), 103 | ) 104 | 105 | // Must create a new tag for the release. 106 | expect(await repo.fs.exec('git tag')).toHaveProperty( 107 | 'stdout', 108 | expect.stringContaining('0.1.0'), 109 | ) 110 | 111 | expect(log.info).toHaveBeenCalledWith('created release: /releases/1') 112 | expect(log.info).toHaveBeenCalledWith('release "v0.1.0" completed!') 113 | }) 114 | 115 | it('releases a new version after an existing version', async () => { 116 | const repo = await createRepository('version-new-after-existing') 117 | 118 | api.use( 119 | graphql.query('GetCommitAuthors', (req, res, ctx) => { 120 | return res(ctx.data({})) 121 | }), 122 | githubLatestReleaseHandler, 123 | rest.post( 124 | 'https://api.github.com/repos/:owner/:repo/releases', 125 | (req, res, ctx) => { 126 | return res( 127 | ctx.status(201), 128 | ctx.json({ 129 | tag_name: 'v1.0.0', 130 | html_url: '/releases/1', 131 | }), 132 | ) 133 | }, 134 | ), 135 | ) 136 | 137 | await repo.fs.create({ 138 | 'package.json': JSON.stringify({ 139 | name: 'test', 140 | version: '1.2.3', 141 | }), 142 | }) 143 | await execAsync(`git commit -m 'chore(release): v1.2.3' --allow-empty`) 144 | await execAsync('git tag v1.2.3') 145 | await execAsync(`git commit -m 'fix: stuff' --allow-empty`) 146 | await execAsync(`git commit -m 'feat: stuff' --allow-empty`) 147 | 148 | const publish = new Publish( 149 | { 150 | profiles: [ 151 | { 152 | name: 'latest', 153 | use: 'echo "release script input: $RELEASE_VERSION"', 154 | }, 155 | ], 156 | }, 157 | { 158 | _: [], 159 | profile: 'latest', 160 | }, 161 | ) 162 | await publish.run() 163 | 164 | expect(log.error).not.toHaveBeenCalled() 165 | expect(log.info).toHaveBeenCalledWith( 166 | expect.stringContaining('found 2 new commits:'), 167 | ) 168 | 169 | expect(log.info).toHaveBeenCalledWith( 170 | expect.stringContaining('found latest release: v1.2.3'), 171 | ) 172 | 173 | // Must notify about the next version. 174 | expect(log.info).toHaveBeenCalledWith('release type "minor": 1.2.3 -> 1.3.0') 175 | 176 | // The release script is provided with the environmental variables. 177 | expect(process.stdout.write).toHaveBeenCalledWith( 178 | 'release script input: 1.3.0\n', 179 | ) 180 | expect(log.info).toHaveBeenCalledWith( 181 | expect.stringContaining('bumped version in package.json to: 1.3.0'), 182 | ) 183 | 184 | // Must bump the "version" in package.json. 185 | expect( 186 | JSON.parse(await repo.fs.readFile('package.json', 'utf8')), 187 | ).toHaveProperty('version', '1.3.0') 188 | 189 | expect(await repo.fs.exec('git log')).toHaveProperty( 190 | 'stdout', 191 | expect.stringContaining('chore(release): v1.3.0'), 192 | ) 193 | 194 | // Must create a new tag for the release. 195 | expect(await repo.fs.exec('git tag')).toHaveProperty( 196 | 'stdout', 197 | expect.stringContaining('v1.3.0'), 198 | ) 199 | 200 | expect(log.info).toHaveBeenCalledWith('created release: /releases/1') 201 | expect(log.info).toHaveBeenCalledWith('release "v1.3.0" completed!') 202 | }) 203 | 204 | it('comments on relevant github issues', async () => { 205 | const repo = await createRepository('issue-comments') 206 | 207 | const commentsCreated = new Map() 208 | 209 | api.use( 210 | graphql.query('GetCommitAuthors', (req, res, ctx) => { 211 | return res( 212 | ctx.data({ 213 | repository: { 214 | pullRequest: { 215 | author: { login: 'octocat' }, 216 | commits: { 217 | nodes: [], 218 | }, 219 | }, 220 | }, 221 | }), 222 | ) 223 | }), 224 | githubLatestReleaseHandler, 225 | rest.post( 226 | 'https://api.github.com/repos/:owner/:repo/releases', 227 | (req, res, ctx) => { 228 | return res( 229 | ctx.status(201), 230 | ctx.json({ 231 | tag_name: 'v1.0.0', 232 | html_url: '/releases/1', 233 | }), 234 | ) 235 | }, 236 | ), 237 | rest.get( 238 | 'https://api.github.com/repos/:owner/:repo/issues/:id', 239 | (req, res, ctx) => { 240 | return res(ctx.json({})) 241 | }, 242 | ), 243 | rest.post<{ body: string }>( 244 | 'https://api.github.com/repos/:owner/:repo/issues/:id/comments', 245 | (req, res, ctx) => { 246 | commentsCreated.set(req.params.id as string, req.body.body) 247 | return res(ctx.status(201)) 248 | }, 249 | ), 250 | ) 251 | 252 | await repo.fs.create({ 253 | 'package.json': JSON.stringify({ 254 | name: 'test', 255 | version: '0.0.0', 256 | }), 257 | }) 258 | await repo.fs.exec( 259 | `git commit -m 'feat: supports graphql (#10)' --allow-empty`, 260 | ) 261 | 262 | const publish = new Publish( 263 | { 264 | profiles: [ 265 | { 266 | name: 'latest', 267 | use: 'echo "release script input: $RELEASE_VERSION"', 268 | }, 269 | ], 270 | }, 271 | { 272 | _: [], 273 | profile: 'latest', 274 | }, 275 | ) 276 | await publish.run() 277 | 278 | expect(log.info).toHaveBeenCalledWith('commenting on 1 GitHub issue:\n - 10') 279 | expect(commentsCreated).toEqual( 280 | new Map([['10', expect.stringContaining('## Released: v0.1.0 🎉')]]), 281 | ) 282 | 283 | expect(log.info).toHaveBeenCalledWith('release "v0.1.0" completed!') 284 | }) 285 | 286 | it('supports dry-run mode', async () => { 287 | const repo = await createRepository('dry-mode') 288 | 289 | const getReleaseContributorsResolver = jest.fn< 290 | ReturnType, 291 | Parameters 292 | >((req, res, ctx) => { 293 | return res(ctx.status(500)) 294 | }) 295 | const createGitHubReleaseResolver = jest.fn< 296 | ReturnType, 297 | Parameters 298 | >((req, res, ctx) => { 299 | return res(ctx.status(500)) 300 | }) 301 | 302 | api.use( 303 | graphql.query('GetCommitAuthors', getReleaseContributorsResolver), 304 | githubLatestReleaseHandler, 305 | rest.post( 306 | 'https://api.github.com/repos/:owner/:repo/releases', 307 | createGitHubReleaseResolver, 308 | ), 309 | rest.get( 310 | 'https://api.github.com/repos/:owner/:repo/issues/:id', 311 | (req, res, ctx) => { 312 | return res(ctx.json({})) 313 | }, 314 | ), 315 | ) 316 | 317 | await repo.fs.create({ 318 | 'package.json': JSON.stringify({ 319 | name: 'test', 320 | version: '1.2.3', 321 | }), 322 | }) 323 | await execAsync(`git commit -m 'chore(release): v1.2.3' --allow-empty`) 324 | await execAsync('git tag v1.2.3') 325 | await execAsync(`git commit -m 'fix: stuff (#2)' --allow-empty`) 326 | await execAsync(`git commit -m 'feat: stuff' --allow-empty`) 327 | 328 | const publish = new Publish( 329 | { 330 | profiles: [ 331 | { 332 | name: 'latest', 333 | use: 'touch release.script.artifact', 334 | }, 335 | ], 336 | }, 337 | { 338 | _: [], 339 | profile: 'latest', 340 | dryRun: true, 341 | }, 342 | ) 343 | await publish.run() 344 | 345 | expect(log.info).toHaveBeenCalledWith( 346 | 'preparing release for "octocat/dry-mode" from branch "main"...', 347 | ) 348 | expect(log.info).toHaveBeenCalledWith( 349 | expect.stringContaining('found 2 new commits:'), 350 | ) 351 | 352 | // Package.json version bump. 353 | expect(log.info).toHaveBeenCalledWith('release type "minor": 1.2.3 -> 1.3.0') 354 | expect(log.warn).toHaveBeenCalledWith( 355 | 'skip version bump in package.json in dry-run mode (next: 1.3.0)', 356 | ) 357 | expect( 358 | JSON.parse(await repo.fs.readFile('package.json', 'utf8')), 359 | ).toHaveProperty('version', '1.2.3') 360 | 361 | // Publishing script. 362 | expect(log.warn).toHaveBeenCalledWith( 363 | 'skip executing publishing script in dry-run mode', 364 | ) 365 | expect( 366 | fileSystem.existsSync(repo.fs.resolve('release.script.artifact')), 367 | ).toBe(false) 368 | 369 | // No release commit must be created. 370 | expect(log.warn).toHaveBeenCalledWith( 371 | 'skip creating a release commit in dry-run mode: "chore(release): v1.3.0"', 372 | ) 373 | expect(log.info).not.toHaveBeenCalledWith('created release commit!') 374 | 375 | // No release tag must be created. 376 | expect(log.warn).toHaveBeenCalledWith( 377 | 'skip creating a release tag in dry-run mode: v1.3.0', 378 | ) 379 | expect(log.info).not.toHaveBeenCalledWith('created release tag "v1.3.0"!') 380 | expect(await execAsync('git tag')).toEqual({ 381 | stderr: '', 382 | stdout: 'v1.2.3\n', 383 | }) 384 | 385 | // Release notes must still be generated. 386 | expect(log.info).toHaveBeenCalledWith( 387 | expect.stringContaining('generated release notes:\n\n## v1.3.0'), 388 | ) 389 | 390 | expect(createGitHubReleaseResolver).not.toHaveBeenCalled() 391 | 392 | // The actual GitHub release must not be created. 393 | expect(log.warn).toHaveBeenCalledWith( 394 | 'skip creating a GitHub release in dry-run mode', 395 | ) 396 | 397 | // Dry mode still gets all release contributors because 398 | // it's a read operation. 399 | expect(getReleaseContributorsResolver).toHaveBeenCalledTimes(1) 400 | 401 | expect(log.warn).toHaveBeenCalledWith( 402 | 'release "v1.3.0" completed in dry-run mode!', 403 | ) 404 | }) 405 | 406 | it('streams the release script stdout to the main process', async () => { 407 | const repo = await createRepository('stream-stdout') 408 | 409 | api.use( 410 | graphql.query('GetCommitAuthors', (req, res, ctx) => { 411 | return res(ctx.data({})) 412 | }), 413 | githubLatestReleaseHandler, 414 | rest.post( 415 | 'https://api.github.com/repos/:owner/:repo/releases', 416 | (req, res, ctx) => { 417 | return res( 418 | ctx.status(201), 419 | ctx.json({ 420 | tag_name: 'v1.0.0', 421 | html_url: '/releases/1', 422 | }), 423 | ) 424 | }, 425 | ), 426 | ) 427 | 428 | await repo.fs.create({ 429 | 'package.json': JSON.stringify({ 430 | name: 'publish-stream', 431 | }), 432 | 'stream-stdout.js': ` 433 | console.log('hello') 434 | setTimeout(() => console.log('world'), 100) 435 | setTimeout(() => process.exit(0), 150) 436 | `, 437 | }) 438 | await execAsync( 439 | `git commit -m 'feat: stream release script stdout' --allow-empty`, 440 | ) 441 | 442 | const publish = new Publish( 443 | { 444 | profiles: [ 445 | { 446 | name: 'latest', 447 | use: 'node stream-stdout.js', 448 | }, 449 | ], 450 | }, 451 | { 452 | _: [], 453 | profile: 'latest', 454 | }, 455 | ) 456 | 457 | await publish.run() 458 | 459 | // Must log the release script stdout. 460 | expect(process.stdout.write).toHaveBeenCalledWith('hello\n') 461 | expect(process.stdout.write).toHaveBeenCalledWith('world\n') 462 | 463 | // Must report a successful release. 464 | expect(log.info).toHaveBeenCalledWith('release type "minor": 0.0.0 -> 0.1.0') 465 | expect(log.info).toHaveBeenCalledWith('release "v0.1.0" completed!') 466 | }) 467 | 468 | it('streams the release script stderr to the main process', async () => { 469 | const repo = await createRepository('stream-stderr') 470 | 471 | api.use( 472 | graphql.query('GetCommitAuthors', (req, res, ctx) => { 473 | return res(ctx.data({})) 474 | }), 475 | githubLatestReleaseHandler, 476 | rest.post( 477 | 'https://api.github.com/repos/:owner/:repo/releases', 478 | (req, res, ctx) => { 479 | return res( 480 | ctx.status(201), 481 | ctx.json({ 482 | tag_name: 'v1.0.0', 483 | html_url: '/releases/1', 484 | }), 485 | ) 486 | }, 487 | ), 488 | ) 489 | 490 | await repo.fs.create({ 491 | 'package.json': JSON.stringify({ 492 | name: 'publish-stream', 493 | }), 494 | 'stream-stderr.js': ` 495 | console.error('something') 496 | setTimeout(() => console.error('went wrong'), 100) 497 | setTimeout(() => process.exit(0), 150) 498 | `, 499 | }) 500 | await execAsync( 501 | `git commit -m 'feat: stream release script stderr' --allow-empty`, 502 | ) 503 | 504 | const publish = new Publish( 505 | { 506 | profiles: [ 507 | { 508 | name: 'latest', 509 | use: 'node stream-stderr.js', 510 | }, 511 | ], 512 | }, 513 | { 514 | _: [], 515 | profile: 'latest', 516 | }, 517 | ) 518 | 519 | await publish.run() 520 | 521 | // Must log the release script stderr. 522 | expect(process.stderr.write).toHaveBeenCalledWith('something\n') 523 | expect(process.stderr.write).toHaveBeenCalledWith('went wrong\n') 524 | 525 | // Must report a successful release. 526 | // As long as the publish script doesn't exit, it is successful. 527 | expect(log.info).toHaveBeenCalledWith('release type "minor": 0.0.0 -> 0.1.0') 528 | expect(log.info).toHaveBeenCalledWith('release "v0.1.0" completed!') 529 | }) 530 | 531 | it('only pushes the newly created release tag to the remote', async () => { 532 | const repo = await createRepository('push-release-tag') 533 | 534 | await repo.fs.create({ 535 | 'package.json': JSON.stringify({ name: 'push-tag', version: '1.0.0' }), 536 | }) 537 | 538 | api.use( 539 | graphql.query('GetCommitAuthors', (req, res, ctx) => { 540 | return res(ctx.data({})) 541 | }), 542 | githubLatestReleaseHandler, 543 | rest.post( 544 | 'https://api.github.com/repos/:owner/:repo/releases', 545 | (req, res, ctx) => { 546 | return res( 547 | ctx.status(201), 548 | ctx.json({ 549 | tag_name: 'v1.0.0', 550 | html_url: '/releases/1', 551 | }), 552 | ) 553 | }, 554 | ), 555 | ) 556 | 557 | // Create an existing tag 558 | await execAsync(`git tag v1.0.0`) 559 | await execAsync(`git push origin v1.0.0`) 560 | 561 | // Create a new commit. 562 | await execAsync(`git commit -m 'feat: new feature' --allow-empty`) 563 | 564 | const publish = new Publish( 565 | { 566 | profiles: [ 567 | { 568 | name: 'latest', 569 | use: 'exit 0', 570 | }, 571 | ], 572 | }, 573 | { 574 | _: [], 575 | profile: 'latest', 576 | }, 577 | ) 578 | await publish.run() 579 | 580 | expect(log.info).toHaveBeenCalledWith('release type "minor": 1.0.0 -> 1.1.0') 581 | expect(log.info).toHaveBeenCalledWith('release "v1.1.0" completed!') 582 | }) 583 | 584 | it('treats breaking changes as minor versions when "prerelease" is set to true', async () => { 585 | const repo = await createRepository('prerelease-major-as-minor') 586 | 587 | api.use( 588 | graphql.query('GetCommitAuthors', (req, res, ctx) => { 589 | return res(ctx.data({})) 590 | }), 591 | githubLatestReleaseHandler, 592 | rest.post( 593 | 'https://api.github.com/repos/:owner/:repo/releases', 594 | (req, res, ctx) => { 595 | return res( 596 | ctx.status(201), 597 | ctx.json({ 598 | tag_name: 'v1.0.0', 599 | html_url: '/releases/1', 600 | }), 601 | ) 602 | }, 603 | ), 604 | ) 605 | 606 | await repo.fs.create({ 607 | 'package.json': JSON.stringify({ 608 | name: 'test', 609 | version: '0.1.2', 610 | }), 611 | }) 612 | await execAsync(`git commit -m 'chore(release): v0.1.2' --allow-empty`) 613 | await execAsync('git tag v0.1.2') 614 | await repo.fs.exec( 615 | `git add . && git commit -m 'feat: new things' -m 'BREAKING CHANGE: beware'`, 616 | ) 617 | 618 | const publish = new Publish( 619 | { 620 | profiles: [ 621 | { 622 | name: 'latest', 623 | use: 'echo "release script input: $RELEASE_VERSION"', 624 | // This forces breaking changes to result in a minor 625 | // version bump. 626 | prerelease: true, 627 | }, 628 | ], 629 | }, 630 | { 631 | _: [], 632 | profile: 'latest', 633 | }, 634 | ) 635 | await publish.run() 636 | 637 | expect(log.error).not.toHaveBeenCalled() 638 | 639 | // Must bump the minor version upon breaking change 640 | // due to the "prerelease" configuration set. 641 | expect(log.info).toHaveBeenCalledWith('release type "minor": 0.1.2 -> 0.2.0') 642 | 643 | // Must expose the correct environment variable 644 | // to the publish script. 645 | expect(process.stdout.write).toHaveBeenCalledWith( 646 | 'release script input: 0.2.0\n', 647 | ) 648 | 649 | // Must bump the "version" in package.json. 650 | expect( 651 | JSON.parse(await repo.fs.readFile('package.json', 'utf8')), 652 | ).toHaveProperty('version', '0.2.0') 653 | 654 | expect(await repo.fs.exec('git log')).toHaveProperty( 655 | 'stdout', 656 | expect.stringContaining('chore(release): v0.2.0'), 657 | ) 658 | 659 | expect(log.info).toHaveBeenCalledWith('release "v0.2.0" completed!') 660 | }) 661 | 662 | it('treats minor bumps as minor versions when "prerelease" is set to true', async () => { 663 | const repo = await createRepository('prerelease-major-as-minor') 664 | 665 | api.use( 666 | graphql.query('GetCommitAuthors', (req, res, ctx) => { 667 | return res(ctx.data({})) 668 | }), 669 | githubLatestReleaseHandler, 670 | rest.post( 671 | 'https://api.github.com/repos/:owner/:repo/releases', 672 | (req, res, ctx) => { 673 | return res( 674 | ctx.status(201), 675 | ctx.json({ 676 | tag_name: 'v1.0.0', 677 | html_url: '/releases/1', 678 | }), 679 | ) 680 | }, 681 | ), 682 | ) 683 | 684 | await repo.fs.create({ 685 | 'package.json': JSON.stringify({ 686 | name: 'test', 687 | version: '0.0.0', 688 | }), 689 | }) 690 | await repo.fs.exec(`git add . && git commit -m 'feat: new things'`) 691 | 692 | const publish = new Publish( 693 | { 694 | profiles: [ 695 | { 696 | name: 'latest', 697 | use: 'echo "release script input: $RELEASE_VERSION"', 698 | // This forces breaking changes to result in a minor 699 | // version bump. 700 | prerelease: true, 701 | }, 702 | ], 703 | }, 704 | { 705 | _: [], 706 | profile: 'latest', 707 | }, 708 | ) 709 | await publish.run() 710 | 711 | expect(log.error).not.toHaveBeenCalled() 712 | 713 | // Must bump the minor version upon breaking change 714 | // due to the "prerelease" configuration set. 715 | expect(log.info).toHaveBeenCalledWith('release type "minor": 0.0.0 -> 0.1.0') 716 | 717 | // Must expose the correct environment variable 718 | // to the publish script. 719 | expect(process.stdout.write).toHaveBeenCalledWith( 720 | 'release script input: 0.1.0\n', 721 | ) 722 | 723 | // Must bump the "version" in package.json. 724 | expect( 725 | JSON.parse(await repo.fs.readFile('package.json', 'utf8')), 726 | ).toHaveProperty('version', '0.1.0') 727 | 728 | expect(await repo.fs.exec('git log')).toHaveProperty( 729 | 'stdout', 730 | expect.stringContaining('chore(release): v0.1.0'), 731 | ) 732 | 733 | expect(log.info).toHaveBeenCalledWith('release "v0.1.0" completed!') 734 | }) 735 | -------------------------------------------------------------------------------- /src/commands/__test__/show.test.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw' 2 | import { ReleaseStatus, Show } from '../../commands/show' 3 | import { log } from '../../logger' 4 | import { execAsync } from '../../utils/execAsync' 5 | import { getTag } from '../../utils/git/getTag' 6 | import { testEnvironment } from '../../../test/env' 7 | import { mockConfig } from '../../../test/fixtures' 8 | import { commit } from '../../utils/git/commit' 9 | 10 | const { setup, reset, cleanup, api, createRepository } = testEnvironment({ 11 | fileSystemPath: 'show', 12 | }) 13 | 14 | beforeAll(async () => { 15 | await setup() 16 | }) 17 | 18 | afterEach(async () => { 19 | await reset() 20 | }) 21 | 22 | afterAll(async () => { 23 | await cleanup() 24 | }) 25 | 26 | it('exits given repository without any releases', async () => { 27 | await createRepository('repo-without-releases') 28 | const show = new Show(mockConfig(), { _: [''] }) 29 | 30 | await expect(show.run()).rejects.toThrow( 31 | 'Failed to retrieve release tag: repository has no releases.', 32 | ) 33 | }) 34 | 35 | it('exits given a non-existing release', async () => { 36 | await createRepository('repo-with-release') 37 | 38 | await execAsync('git commit -m "chore: release v1.0.0" --allow-empty') 39 | await execAsync(`git tag v1.0.0`) 40 | const show = new Show(mockConfig(), { _: ['', 'v1.2.3'] }) 41 | 42 | await expect(show.run()).rejects.toThrow( 43 | 'Failed to retrieve release tag: tag "v1.2.3" does not exist.', 44 | ) 45 | }) 46 | 47 | it('displays info for explicit unpublished release', async () => { 48 | await createRepository('repo-with-unpublished-release') 49 | 50 | api.use( 51 | rest.get( 52 | 'https://api.github.com/repos/:owner/:repo/releases/tags/v1.0.0', 53 | (req, res, ctx) => { 54 | return res(ctx.status(404), ctx.json({})) 55 | }, 56 | ), 57 | ) 58 | 59 | await execAsync('git commit -m "chore: release v1.0.0" --allow-empty') 60 | await execAsync(`git tag v1.0.0`) 61 | const pointer = await getTag('v1.0.0') 62 | 63 | const show = new Show(mockConfig(), { _: ['', 'v1.0.0'] }) 64 | await show.run() 65 | 66 | expect(log.info).toHaveBeenCalledWith('found tag "v1.0.0"!') 67 | expect(log.info).toHaveBeenCalledWith( 68 | expect.stringContaining(`commit ${pointer!.hash}`), 69 | ) 70 | expect(log.info).toHaveBeenCalledWith( 71 | `release status: ${ReleaseStatus.Unpublished}`, 72 | ) 73 | expect(log.info).not.toHaveBeenCalledWith( 74 | expect.stringContaining('release url:'), 75 | ) 76 | }) 77 | 78 | it('displays info for explicit draft release', async () => { 79 | await createRepository('repo-with-draft-release') 80 | 81 | api.use( 82 | rest.get( 83 | 'https://api.github.com/repos/:owner/:repo/releases/tags/v1.0.0', 84 | (req, res, ctx) => { 85 | return res( 86 | ctx.json({ 87 | draft: true, 88 | html_url: '/releases/v1.0.0', 89 | }), 90 | ) 91 | }, 92 | ), 93 | ) 94 | 95 | await execAsync('git commit -m "chore: release v1.0.0" --allow-empty') 96 | await execAsync(`git tag v1.0.0`) 97 | const pointer = await getTag('v1.0.0') 98 | 99 | const show = new Show(mockConfig(), { _: ['', 'v1.0.0'] }) 100 | await show.run() 101 | 102 | expect(log.info).toHaveBeenCalledWith('found tag "v1.0.0"!') 103 | expect(log.info).toHaveBeenCalledWith( 104 | expect.stringContaining(`commit ${pointer!.hash}`), 105 | ) 106 | expect(log.info).toHaveBeenCalledWith( 107 | `release status: ${ReleaseStatus.Draft}`, 108 | ) 109 | expect(log.info).toHaveBeenCalledWith('release url: /releases/v1.0.0') 110 | }) 111 | 112 | it('displays info for explicit public release', async () => { 113 | await createRepository('repo-with-public-release') 114 | 115 | api.use( 116 | rest.get( 117 | 'https://api.github.com/repos/:owner/:repo/releases/tags/v1.0.0', 118 | (req, res, ctx) => { 119 | return res( 120 | ctx.json({ 121 | html_url: '/releases/v1.0.0', 122 | }), 123 | ) 124 | }, 125 | ), 126 | ) 127 | 128 | await execAsync('git commit -m "chore: release v1.0.0" --allow-empty') 129 | await execAsync(`git tag v1.0.0`) 130 | const pointer = await getTag('v1.0.0') 131 | 132 | const show = new Show(mockConfig(), { _: ['', 'v1.0.0'] }) 133 | await show.run() 134 | 135 | expect(log.info).toHaveBeenCalledWith('found tag "v1.0.0"!') 136 | expect(log.info).toHaveBeenCalledWith( 137 | expect.stringContaining(`commit ${pointer!.hash}`), 138 | ) 139 | expect(log.info).toHaveBeenCalledWith( 140 | `release status: ${ReleaseStatus.Public}`, 141 | ) 142 | expect(log.info).toHaveBeenCalledWith('release url: /releases/v1.0.0') 143 | }) 144 | 145 | it('displays info for implicit unpublished release', async () => { 146 | await createRepository('repo-with-implicit-unpublished-release') 147 | 148 | api.use( 149 | rest.get( 150 | 'https://api.github.com/repos/:owner/:repo/releases/tags/v1.2.3', 151 | (req, res, ctx) => { 152 | return res(ctx.status(404), ctx.json({})) 153 | }, 154 | ), 155 | ) 156 | 157 | const releaseCommit = await commit({ 158 | message: 'chore(release): v1.2.3', 159 | allowEmpty: true, 160 | }) 161 | await execAsync(`git tag v1.2.3`) 162 | 163 | const show = new Show(mockConfig(), { _: [''] }) 164 | await show.run() 165 | 166 | expect(log.info).toHaveBeenCalledWith('found tag "v1.2.3"!') 167 | expect(log.info).toHaveBeenCalledWith( 168 | expect.stringContaining(`commit ${releaseCommit.hash}`), 169 | ) 170 | expect(log.info).toHaveBeenCalledWith( 171 | `release status: ${ReleaseStatus.Unpublished}`, 172 | ) 173 | expect(log.info).not.toHaveBeenCalledWith( 174 | expect.stringContaining('release url:'), 175 | ) 176 | }) 177 | 178 | it('displays info for explicit draft release', async () => { 179 | await createRepository('repo-with-explicit-draft-release') 180 | 181 | api.use( 182 | rest.get( 183 | 'https://api.github.com/repos/:owner/:repo/releases/tags/v1.2.3', 184 | (req, res, ctx) => { 185 | return res( 186 | ctx.json({ 187 | draft: true, 188 | html_url: '/releases/v1.2.3', 189 | }), 190 | ) 191 | }, 192 | ), 193 | ) 194 | 195 | const releaseCommit = await commit({ 196 | message: 'chore(release): v1.2.3', 197 | allowEmpty: true, 198 | }) 199 | await execAsync(`git tag v1.2.3`) 200 | 201 | const show = new Show(mockConfig(), { _: [''] }) 202 | await show.run() 203 | 204 | expect(log.info).toHaveBeenCalledWith('found tag "v1.2.3"!') 205 | expect(log.info).toHaveBeenCalledWith( 206 | expect.stringContaining(`commit ${releaseCommit.hash}`), 207 | ) 208 | expect(log.info).toHaveBeenCalledWith( 209 | `release status: ${ReleaseStatus.Draft}`, 210 | ) 211 | expect(log.info).toHaveBeenCalledWith('release url: /releases/v1.2.3') 212 | }) 213 | 214 | it('displays info for explicit public release', async () => { 215 | await createRepository('repo-with-explicit-public-release') 216 | 217 | api.use( 218 | rest.get( 219 | 'https://api.github.com/repos/:owner/:repo/releases/tags/v1.2.3', 220 | (req, res, ctx) => { 221 | return res( 222 | ctx.json({ 223 | html_url: '/releases/v1.2.3', 224 | }), 225 | ) 226 | }, 227 | ), 228 | ) 229 | 230 | const releaseCommit = await commit({ 231 | message: 'chore(release): v1.2.3', 232 | allowEmpty: true, 233 | }) 234 | await execAsync(`git tag v1.2.3`) 235 | 236 | const show = new Show(mockConfig(), { _: [''] }) 237 | await show.run() 238 | 239 | expect(log.info).toHaveBeenCalledWith('found tag "v1.2.3"!') 240 | expect(log.info).toHaveBeenCalledWith( 241 | expect.stringContaining(`commit ${releaseCommit.hash}`), 242 | ) 243 | expect(log.info).toHaveBeenCalledWith( 244 | `release status: ${ReleaseStatus.Public}`, 245 | ) 246 | expect(log.info).toHaveBeenCalledWith('release url: /releases/v1.2.3') 247 | }) 248 | -------------------------------------------------------------------------------- /src/commands/notes.ts: -------------------------------------------------------------------------------- 1 | import { format, invariant } from 'outvariant' 2 | import type { BuilderCallback } from 'yargs' 3 | import type { ReleaseContext } from '../utils/createContext' 4 | import { demandGitHubToken } from '../utils/env' 5 | import { createGitHubRelease } from '../utils/github/createGitHubRelease' 6 | import { Command } from '../Command' 7 | import { getInfo } from '../utils/git/getInfo' 8 | import { parseCommits, ParsedCommitWithHash } from '../utils/git/parseCommits' 9 | import { getReleaseNotes } from '../utils/release-notes/getReleaseNotes' 10 | import { toMarkdown } from '../utils/release-notes/toMarkdown' 11 | import { getCommits } from '../utils/git/getCommits' 12 | import { getTag } from '../utils/git/getTag' 13 | import { getCommit } from '../utils/git/getCommit' 14 | import { byReleaseVersion } from '../utils/git/getLatestRelease' 15 | import { getTags } from '../utils/git/getTags' 16 | import { 17 | getGitHubRelease, 18 | GitHubRelease, 19 | } from '../utils/github/getGitHubRelease' 20 | 21 | interface Argv { 22 | _: [path: string, tag: string] 23 | } 24 | 25 | export class Notes extends Command { 26 | static command = 'notes' 27 | static description = 28 | 'Generate GitHub release notes for the given release version.' 29 | 30 | static builder: BuilderCallback<{}, Argv> = (yargs) => { 31 | return yargs.usage('$ notes [tag]').positional('tag', { 32 | type: 'string', 33 | desciption: 'Release tag', 34 | demandOption: true, 35 | }) 36 | } 37 | 38 | public run = async () => { 39 | await demandGitHubToken().catch((error) => { 40 | this.log.error(error.message) 41 | process.exit(1) 42 | }) 43 | 44 | const repo = await getInfo() 45 | 46 | const [, tagInput] = this.argv._ 47 | const tagName = tagInput.startsWith('v') ? tagInput : `v${tagInput}` 48 | const version = tagInput.replace(/^v/, '') 49 | 50 | // Check if there's an existing GitHub release for the given tag. 51 | const existingRelease = await getGitHubRelease(tagName) 52 | 53 | if (existingRelease) { 54 | this.log.warn( 55 | format( 56 | 'found existing GitHub release for "%s": %s', 57 | tagName, 58 | existingRelease.html_url, 59 | ), 60 | ) 61 | return process.exit(1) 62 | } 63 | 64 | this.log.info( 65 | format( 66 | 'creating GitHub release for version "%s" in "%s/%s"...', 67 | tagName, 68 | repo.owner, 69 | repo.name, 70 | ), 71 | ) 72 | 73 | // Retrieve the information about the given release version. 74 | const tagPointer = await getTag(tagName) 75 | invariant( 76 | tagPointer, 77 | 'Failed to create GitHub release: unknown tag "%s". Please make sure you are providing an existing release tag.', 78 | tagName, 79 | ) 80 | 81 | this.log.info( 82 | format('found release tag "%s" (%s)', tagPointer.tag, tagPointer.hash), 83 | ) 84 | 85 | const releaseCommit = await getCommit(tagPointer.hash) 86 | invariant( 87 | releaseCommit, 88 | 'Failed to create GitHub release: unable to retrieve the commit by tag "%s" (%s).', 89 | tagPointer.tag, 90 | tagPointer.hash, 91 | ) 92 | 93 | // Retrieve the pointer to the previous release. 94 | const tags = await getTags().then((tags) => { 95 | return tags.sort(byReleaseVersion) 96 | }) 97 | 98 | const tagReleaseIndex = tags.indexOf(tagPointer.tag) 99 | const previousReleaseTag = tags[tagReleaseIndex + 1] 100 | 101 | const previousRelease = previousReleaseTag 102 | ? await getTag(previousReleaseTag) 103 | : undefined 104 | 105 | if (previousRelease?.hash) { 106 | this.log.info( 107 | format( 108 | 'found preceding release "%s" (%s)', 109 | previousRelease.tag, 110 | previousRelease.hash, 111 | ), 112 | ) 113 | } else { 114 | this.log.info( 115 | format( 116 | 'found no released preceding "%s": analyzing all commits until "%s"...', 117 | tagPointer.tag, 118 | tagPointer.hash, 119 | ), 120 | ) 121 | } 122 | 123 | // Get commits list between the given release and the previous release. 124 | const commits = await getCommits({ 125 | since: previousRelease?.hash, 126 | until: tagPointer.hash, 127 | }).then(parseCommits) 128 | 129 | const context: ReleaseContext = { 130 | repo, 131 | nextRelease: { 132 | version, 133 | tag: tagPointer.tag, 134 | publishedAt: releaseCommit.author.date, 135 | }, 136 | latestRelease: previousRelease, 137 | } 138 | 139 | // Generate release notes for the commits. 140 | const releaseNotes = await Notes.generateReleaseNotes(context, commits) 141 | this.log.info(format('generated release notes:\n%s', releaseNotes)) 142 | 143 | // Create GitHub release. 144 | const release = await Notes.createRelease(context, releaseNotes) 145 | this.log.info(format('created GitHub release: %s', release.html_url)) 146 | } 147 | 148 | static async generateReleaseNotes( 149 | context: ReleaseContext, 150 | commits: ParsedCommitWithHash[], 151 | ): Promise { 152 | const releaseNotes = await getReleaseNotes(commits) 153 | const markdown = toMarkdown(context, releaseNotes) 154 | return markdown 155 | } 156 | 157 | static async createRelease( 158 | context: ReleaseContext, 159 | notes: string, 160 | ): Promise { 161 | return createGitHubRelease(context, notes) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/commands/publish.ts: -------------------------------------------------------------------------------- 1 | import { until } from '@open-draft/until' 2 | import { invariant, format } from 'outvariant' 3 | import { BuilderCallback } from 'yargs' 4 | import { Command } from '../Command' 5 | import { createContext, ReleaseContext } from '../utils/createContext' 6 | import { getInfo } from '../utils/git/getInfo' 7 | import { getNextReleaseType } from '../utils/getNextReleaseType' 8 | import { getNextVersion } from '../utils/getNextVersion' 9 | import { getCommits } from '../utils/git/getCommits' 10 | import { getCurrentBranch } from '../utils/git/getCurrentBranch' 11 | import { getLatestRelease } from '../utils/git/getLatestRelease' 12 | import { bumpPackageJson } from '../utils/bumpPackageJson' 13 | import { getTags } from '../utils/git/getTags' 14 | import { execAsync } from '../utils/execAsync' 15 | import { commit } from '../utils/git/commit' 16 | import { createTag } from '../utils/git/createTag' 17 | import { push } from '../utils/git/push' 18 | import { getReleaseRefs } from '../utils/release-notes/getReleaseRefs' 19 | import { parseCommits, ParsedCommitWithHash } from '../utils/git/parseCommits' 20 | import { createComment } from '../utils/github/createComment' 21 | import { createReleaseComment } from '../utils/createReleaseComment' 22 | import { demandGitHubToken, demandNpmToken } from '../utils/env' 23 | import { Notes } from './notes' 24 | import { ReleaseProfile } from '../utils/getConfig' 25 | 26 | interface PublishArgv { 27 | profile: string 28 | dryRun?: boolean 29 | } 30 | 31 | export type RevertAction = () => Promise 32 | 33 | export class Publish extends Command { 34 | static command = 'publish' 35 | static description = 'Publish the package' 36 | static builder: BuilderCallback<{}, PublishArgv> = (yargs) => { 37 | return yargs 38 | .usage('$0 publish [options]') 39 | .option('profile', { 40 | alias: 'p', 41 | type: 'string', 42 | default: 'latest', 43 | demandOption: true, 44 | }) 45 | .option('dry-run', { 46 | alias: 'd', 47 | type: 'boolean', 48 | default: false, 49 | demandOption: false, 50 | description: 'Print command steps without executing them', 51 | }) 52 | } 53 | 54 | private profile: ReleaseProfile = null as any 55 | private context: ReleaseContext = null as any 56 | 57 | /** 58 | * The list of clean-up functions to invoke if release fails. 59 | */ 60 | private revertQueue: Array = [] 61 | 62 | public run = async (): Promise => { 63 | const profileName = this.argv.profile 64 | const profileDefinition = this.config.profiles.find((definedProfile) => { 65 | return definedProfile.name === profileName 66 | }) 67 | 68 | invariant( 69 | profileDefinition, 70 | 'Failed to publish: no profile found by name "%s". Did you forget to define it in "release.config.json"?', 71 | profileName, 72 | ) 73 | 74 | this.profile = profileDefinition 75 | 76 | await demandGitHubToken().catch((error) => { 77 | this.log.error(error.message) 78 | process.exit(1) 79 | }) 80 | 81 | await demandNpmToken().catch((error) => { 82 | this.log.error(error.message) 83 | process.exit(1) 84 | }) 85 | 86 | this.revertQueue = [] 87 | 88 | // Extract repository information (remote/owner/name). 89 | const repo = await getInfo().catch((error) => { 90 | console.error(error) 91 | throw new Error('Failed to get Git repository information') 92 | }) 93 | const branchName = await getCurrentBranch().catch((error) => { 94 | console.error(error) 95 | throw new Error('Failed to get the current branch name') 96 | }) 97 | 98 | this.log.info( 99 | format( 100 | 'preparing release for "%s/%s" from branch "%s"...', 101 | repo.owner, 102 | repo.name, 103 | branchName, 104 | ), 105 | ) 106 | 107 | /** 108 | * Get the latest release. 109 | * @note This refers to the latest release tag at the current 110 | * state of the branch. Since Release doesn't do branch analysis, 111 | * this doesn't guarantee the latest release in general 112 | * (consider backport releases where you checkout an old SHA). 113 | */ 114 | const tags = await getTags() 115 | const latestRelease = await getLatestRelease(tags) 116 | 117 | if (latestRelease) { 118 | this.log.info( 119 | format( 120 | 'found latest release: %s (%s)', 121 | latestRelease.tag, 122 | latestRelease.hash, 123 | ), 124 | ) 125 | } else { 126 | this.log.info('found no previous releases, creating the first one...') 127 | } 128 | 129 | const rawCommits = await getCommits({ 130 | since: latestRelease?.hash, 131 | }) 132 | 133 | this.log.info( 134 | format( 135 | 'found %d new %s:\n%s', 136 | rawCommits.length, 137 | rawCommits.length > 1 ? 'commits' : 'commit', 138 | rawCommits 139 | .map((commit) => format(' - %s %s', commit.hash, commit.subject)) 140 | .join('\n'), 141 | ), 142 | ) 143 | 144 | const commits = await parseCommits(rawCommits) 145 | this.log.info(format('successfully parsed %d commit(s)!', commits.length)) 146 | 147 | if (commits.length === 0) { 148 | this.log.warn('no commits since the latest release, skipping...') 149 | return 150 | } 151 | 152 | // Get the next release type and version number. 153 | const nextReleaseType = getNextReleaseType(commits, { 154 | prerelease: this.profile.prerelease, 155 | }) 156 | if (!nextReleaseType) { 157 | this.log.warn('committed changes do not bump version, skipping...') 158 | return 159 | } 160 | 161 | const prevVersion = latestRelease?.tag || 'v0.0.0' 162 | const nextVersion = getNextVersion(prevVersion, nextReleaseType) 163 | 164 | this.context = createContext({ 165 | repo, 166 | latestRelease, 167 | nextRelease: { 168 | version: nextVersion, 169 | publishedAt: new Date(), 170 | }, 171 | }) 172 | 173 | this.log.info( 174 | format( 175 | 'release type "%s": %s -> %s', 176 | nextReleaseType, 177 | prevVersion.replace(/^v/, ''), 178 | this.context.nextRelease.version, 179 | ), 180 | ) 181 | 182 | // Bump the version in package.json without committing it. 183 | if (this.argv.dryRun) { 184 | this.log.warn( 185 | format( 186 | 'skip version bump in package.json in dry-run mode (next: %s)', 187 | nextVersion, 188 | ), 189 | ) 190 | } else { 191 | bumpPackageJson(nextVersion) 192 | this.log.info( 193 | format('bumped version in package.json to: %s', nextVersion), 194 | ) 195 | } 196 | 197 | // Execute the publishing script. 198 | await this.runReleaseScript() 199 | 200 | const result = await until(async () => { 201 | await this.createReleaseCommit() 202 | await this.createReleaseTag() 203 | await this.pushToRemote() 204 | const releaseNotes = await this.generateReleaseNotes(commits) 205 | const releaseUrl = await this.createGitHubRelease(releaseNotes) 206 | 207 | return { 208 | releaseUrl, 209 | } 210 | }) 211 | 212 | // Handle any errors during the release process the same way. 213 | if (result.error) { 214 | this.log.error(result.error.message) 215 | 216 | /** 217 | * @todo Suggest a standalone command to repeat the commit/tag/release 218 | * part of the publishing. The actual publish script was called anyway, 219 | * so the package has been published at this point, just the Git info 220 | * updates are missing. 221 | */ 222 | this.log.error('release failed, reverting changes...') 223 | 224 | // Revert changes in case of errors. 225 | await this.revertChanges() 226 | 227 | return process.exit(1) 228 | } 229 | 230 | // Comment on each relevant GitHub issue. 231 | await this.commentOnIssues(commits, result.data.releaseUrl) 232 | 233 | if (this.argv.dryRun) { 234 | this.log.warn( 235 | format( 236 | 'release "%s" completed in dry-run mode!', 237 | this.context.nextRelease.tag, 238 | ), 239 | ) 240 | return 241 | } 242 | 243 | this.log.info( 244 | format('release "%s" completed!', this.context.nextRelease.tag), 245 | ) 246 | } 247 | 248 | /** 249 | * Execute the release script specified in the configuration. 250 | */ 251 | private async runReleaseScript(): Promise { 252 | const env = { 253 | RELEASE_VERSION: this.context.nextRelease.version, 254 | } 255 | 256 | this.log.info( 257 | format('preparing to run the publishing script with:\n%j', env), 258 | ) 259 | 260 | if (this.argv.dryRun) { 261 | this.log.warn('skip executing publishing script in dry-run mode') 262 | return 263 | } 264 | 265 | this.log.info( 266 | format('executing publishing script for profile "%s": %s'), 267 | this.profile.name, 268 | this.profile.use, 269 | ) 270 | 271 | const releaseScriptPromise = execAsync(this.profile.use, { 272 | env: { 273 | ...process.env, 274 | ...env, 275 | }, 276 | }) 277 | 278 | // Forward the publish script's stdio to the logger. 279 | releaseScriptPromise.io.stdout?.pipe(process.stdout) 280 | releaseScriptPromise.io.stderr?.pipe(process.stderr) 281 | 282 | await releaseScriptPromise.catch((error) => { 283 | this.log.error(error) 284 | this.log.error( 285 | 'Failed to publish: the publish script errored. See the original error above.', 286 | ) 287 | process.exit(releaseScriptPromise.io.exitCode || 1) 288 | }) 289 | 290 | this.log.info('published successfully!') 291 | } 292 | 293 | /** 294 | * Revert those changes that were marked as revertable. 295 | */ 296 | private async revertChanges(): Promise { 297 | let revert: RevertAction | undefined 298 | 299 | while ((revert = this.revertQueue.pop())) { 300 | await revert() 301 | } 302 | } 303 | 304 | /** 305 | * Create a release commit in Git. 306 | */ 307 | private async createReleaseCommit(): Promise { 308 | const message = `chore(release): ${this.context.nextRelease.tag}` 309 | 310 | if (this.argv.dryRun) { 311 | this.log.warn( 312 | format('skip creating a release commit in dry-run mode: "%s"', message), 313 | ) 314 | return 315 | } 316 | 317 | const commitResult = await until(() => { 318 | return commit({ 319 | files: ['package.json'], 320 | message, 321 | }) 322 | }) 323 | 324 | invariant( 325 | commitResult.error == null, 326 | 'Failed to create release commit!\n', 327 | commitResult.error, 328 | ) 329 | 330 | this.log.info( 331 | format('created a release commit at "%s"!', commitResult.data.hash), 332 | ) 333 | 334 | this.revertQueue.push(async () => { 335 | this.log.info('reverting the release commit...') 336 | 337 | const hasChanges = await execAsync('git diff') 338 | 339 | if (hasChanges) { 340 | this.log.info('detected uncommitted changes, stashing...') 341 | await execAsync('git stash') 342 | } 343 | 344 | await execAsync('git reset --hard HEAD~1').finally(async () => { 345 | if (hasChanges) { 346 | this.log.info('unstashing uncommitted changes...') 347 | await execAsync('git stash pop') 348 | } 349 | }) 350 | }) 351 | } 352 | 353 | /** 354 | * Create a release tag in Git. 355 | */ 356 | private async createReleaseTag(): Promise { 357 | const nextTag = this.context.nextRelease.tag 358 | 359 | if (this.argv.dryRun) { 360 | this.log.warn( 361 | format('skip creating a release tag in dry-run mode: %s', nextTag), 362 | ) 363 | return 364 | } 365 | 366 | const tagResult = await until(async () => { 367 | const tag = await createTag(nextTag) 368 | await execAsync(`git push origin ${tag}`) 369 | return tag 370 | }) 371 | 372 | invariant( 373 | tagResult.error == null, 374 | 'Failed to tag the release!\n', 375 | tagResult.error, 376 | ) 377 | 378 | this.revertQueue.push(async () => { 379 | const tagToRevert = this.context.nextRelease.tag 380 | this.log.info(format('reverting the release tag "%s"...', tagToRevert)) 381 | 382 | await execAsync(`git tag -d ${tagToRevert}`) 383 | await execAsync(`git push --delete origin ${tagToRevert}`) 384 | }) 385 | 386 | this.log.info(format('created release tag "%s"!', tagResult.data)) 387 | } 388 | 389 | /** 390 | * Generate release notes from the given commits. 391 | */ 392 | private async generateReleaseNotes( 393 | commits: ParsedCommitWithHash[], 394 | ): Promise { 395 | this.log.info( 396 | format('generating release notes for %d commits...', commits.length), 397 | ) 398 | 399 | const releaseNotes = await Notes.generateReleaseNotes(this.context, commits) 400 | this.log.info(`generated release notes:\n\n${releaseNotes}\n`) 401 | 402 | return releaseNotes 403 | } 404 | 405 | /** 406 | * Push the release commit and tag to the remote. 407 | */ 408 | private async pushToRemote(): Promise { 409 | if (this.argv.dryRun) { 410 | this.log.warn('skip pushing release to Git in dry-run mode') 411 | return 412 | } 413 | 414 | const pushResult = await until(() => push()) 415 | 416 | invariant( 417 | pushResult.error == null, 418 | 'Failed to push changes to origin!\n', 419 | pushResult.error, 420 | ) 421 | 422 | this.log.info( 423 | format('pushed changes to "%s" (origin)!', this.context.repo.remote), 424 | ) 425 | } 426 | 427 | /** 428 | * Create a new GitHub release. 429 | */ 430 | private async createGitHubRelease(releaseNotes: string): Promise { 431 | this.log.info('creating a new GitHub release...') 432 | 433 | if (this.argv.dryRun) { 434 | this.log.warn('skip creating a GitHub release in dry-run mode') 435 | return '#' 436 | } 437 | 438 | const release = await Notes.createRelease(this.context, releaseNotes) 439 | const { html_url: releaseUrl } = release 440 | this.log.info(format('created release: %s', releaseUrl)) 441 | 442 | return releaseUrl 443 | } 444 | 445 | /** 446 | * Comment on referenced GitHub issues and pull requests. 447 | */ 448 | private async commentOnIssues( 449 | commits: ParsedCommitWithHash[], 450 | releaseUrl: string, 451 | ): Promise { 452 | this.log.info('commenting on referenced GitHib issues...') 453 | 454 | const referencedIssueIds = await getReleaseRefs(commits) 455 | const issuesCount = referencedIssueIds.size 456 | const releaseCommentText = createReleaseComment({ 457 | context: this.context, 458 | releaseUrl, 459 | }) 460 | 461 | if (issuesCount === 0) { 462 | this.log.info('no referenced GitHub issues, nothing to comment!') 463 | return 464 | } 465 | 466 | this.log.info(format('found %d referenced GitHub issues!', issuesCount)) 467 | 468 | const issuesNoun = issuesCount === 1 ? 'issue' : 'issues' 469 | const issuesDisplayList = Array.from(referencedIssueIds) 470 | .map((id) => ` - ${id}`) 471 | .join('\n') 472 | 473 | if (this.argv.dryRun) { 474 | this.log.warn( 475 | format( 476 | 'skip commenting on %d GitHub %s:\n%s', 477 | issuesCount, 478 | issuesNoun, 479 | issuesDisplayList, 480 | ), 481 | ) 482 | return 483 | } 484 | 485 | this.log.info( 486 | format( 487 | 'commenting on %d GitHub %s:\n%s', 488 | issuesCount, 489 | issuesNoun, 490 | issuesDisplayList, 491 | ), 492 | ) 493 | 494 | const commentPromises: Promise[] = [] 495 | for (const issueId of referencedIssueIds) { 496 | commentPromises.push( 497 | createComment(issueId, releaseCommentText).catch((error) => { 498 | this.log.error( 499 | format('commenting on issue "%s" failed: %s', error.message), 500 | ) 501 | }), 502 | ) 503 | } 504 | 505 | await Promise.allSettled(commentPromises) 506 | } 507 | } 508 | -------------------------------------------------------------------------------- /src/commands/show.ts: -------------------------------------------------------------------------------- 1 | import type { BuilderCallback } from 'yargs' 2 | import { format, invariant } from 'outvariant' 3 | import fetch from 'node-fetch' 4 | import { Command } from '../Command' 5 | import { getTag, TagPointer } from '../utils/git/getTag' 6 | import { getLatestRelease } from '../utils/git/getLatestRelease' 7 | import { getTags } from '../utils/git/getTags' 8 | import { getCommit } from '../utils/git/getCommit' 9 | import { getInfo } from '../utils/git/getInfo' 10 | import { execAsync } from '../utils/execAsync' 11 | import { demandGitHubToken } from '../utils/env' 12 | 13 | interface Argv { 14 | _: [path: string, tag?: string] 15 | } 16 | 17 | export enum ReleaseStatus { 18 | /** 19 | * Release is public and available for everybody to see 20 | * on the GitHub releases page. 21 | */ 22 | Public = 'public', 23 | /** 24 | * Release is pushed to GitHub but is marked as draft. 25 | */ 26 | Draft = 'draft', 27 | /** 28 | * Release is local, not present on GitHub. 29 | */ 30 | Unpublished = 'unpublished', 31 | } 32 | 33 | export class Show extends Command { 34 | static command = 'show' 35 | static description = 'Show release info' 36 | static builder: BuilderCallback<{}, Argv> = (yargs) => { 37 | return yargs 38 | .usage('$0 show [tag]') 39 | .example([ 40 | ['$0 show', 'Show the latest release info'], 41 | ['$0 show 1.2.3', 'Show specific release tag info'], 42 | ]) 43 | .positional('tag', { 44 | type: 'string', 45 | description: 'Release tag', 46 | demandOption: false, 47 | }) 48 | } 49 | 50 | public run = async () => { 51 | await demandGitHubToken().catch((error) => { 52 | this.log.error(error.message) 53 | process.exit(1) 54 | }) 55 | 56 | const [, tag] = this.argv._ 57 | 58 | const pointer = await this.getTagPointer(tag?.toString()) 59 | this.log.info(format('found tag "%s"!', pointer.tag)) 60 | 61 | const commit = await getCommit(pointer.hash) 62 | 63 | invariant( 64 | commit, 65 | 'Failed to retrieve release info for tag "%s": cannot find commit associated with the tag.', 66 | tag, 67 | ) 68 | 69 | // Print local Git info about the release commit. 70 | const commitOut = await execAsync(`git log -1 ${commit.commit.long}`).then( 71 | ({ stdout }) => stdout, 72 | ) 73 | this.log.info(commitOut) 74 | 75 | // Print the remote GitHub info about the release. 76 | const repo = await getInfo() 77 | 78 | const releaseResponse = await fetch( 79 | `https://api.github.com/repos/${repo.owner}/${repo.name}/releases/tags/${pointer.tag}`, 80 | { 81 | headers: { 82 | Authorization: `token ${process.env.GITHUB_TOKEN}`, 83 | }, 84 | }, 85 | ) 86 | 87 | const isPublishedRelease = releaseResponse.status === 200 88 | const release = await releaseResponse.json() 89 | 90 | const releaseStatus: ReleaseStatus = isPublishedRelease 91 | ? release.draft 92 | ? ReleaseStatus.Draft 93 | : ReleaseStatus.Public 94 | : ReleaseStatus.Unpublished 95 | 96 | this.log.info(format('release status: %s', releaseStatus)) 97 | 98 | if ( 99 | releaseStatus === ReleaseStatus.Public || 100 | releaseStatus === ReleaseStatus.Draft 101 | ) { 102 | this.log.info(format('release url: %s', release?.html_url)) 103 | } 104 | 105 | if (!isPublishedRelease) { 106 | this.log.warn( 107 | format('release "%s" is not published to GitHub!', pointer.tag), 108 | ) 109 | } 110 | } 111 | 112 | /** 113 | * Returns tag pointer by the given tag name. 114 | * If no tag name was given, looks up the latest release tag 115 | * and returns its pointer. 116 | */ 117 | private async getTagPointer(tag?: string): Promise { 118 | if (tag) { 119 | this.log.info(format('looking up explicit "%s" tag...', tag)) 120 | const pointer = await getTag(tag) 121 | 122 | invariant( 123 | pointer, 124 | 'Failed to retrieve release tag: tag "%s" does not exist.', 125 | tag, 126 | ) 127 | 128 | return pointer 129 | } 130 | 131 | this.log.info('looking up the latest release tag...') 132 | const tags = await getTags() 133 | 134 | invariant( 135 | tags.length > 0, 136 | 'Failed to retrieve release tag: repository has no releases.', 137 | ) 138 | 139 | const latestPointer = await getLatestRelease(tags) 140 | 141 | invariant( 142 | latestPointer, 143 | 'Failed to retrieve release tag: cannot retrieve releases.', 144 | ) 145 | 146 | return latestPointer 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as yargs from 'yargs' 2 | import { getConfig } from './utils/getConfig' 3 | 4 | // Commands. 5 | import { Show } from './commands/show' 6 | import { Publish } from './commands/publish' 7 | import { Notes } from './commands/notes' 8 | 9 | const config = getConfig() 10 | 11 | yargs 12 | .usage('$0 [options]') 13 | .command(Publish.command, Publish.description, Publish.builder, (argv) => 14 | new Publish(config, argv).run(), 15 | ) 16 | .command(Notes.command, Notes.description, Notes.builder, (argv) => { 17 | return new Notes(config, argv).run() 18 | }) 19 | .command(Show.command, Show.description, Show.builder, (argv) => 20 | new Show(config, argv).run(), 21 | ) 22 | .help().argv 23 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import pino from 'pino' 2 | 3 | export const log = pino({ 4 | base: null, 5 | transport: { 6 | target: 'pino-pretty', 7 | options: { 8 | colorize: true, 9 | timestampKey: false, 10 | }, 11 | }, 12 | }) 13 | -------------------------------------------------------------------------------- /src/utils/__test__/createContext.test.ts: -------------------------------------------------------------------------------- 1 | import { ReleaseContext, createContext } from '../createContext' 2 | 3 | it('creates a context object', () => { 4 | const context = createContext({ 5 | repo: { 6 | owner: 'octocat', 7 | name: 'test', 8 | remote: '@git@github.com:octocat/test.git', 9 | url: 'https://github.com/octocat/test', 10 | }, 11 | latestRelease: undefined, 12 | nextRelease: { 13 | version: '1.2.3', 14 | publishedAt: new Date('20 Apr 2022 12:00:000 GMT'), 15 | }, 16 | }) 17 | 18 | expect(context).toEqual({ 19 | repo: { 20 | owner: 'octocat', 21 | name: 'test', 22 | remote: '@git@github.com:octocat/test.git', 23 | url: 'https://github.com/octocat/test', 24 | }, 25 | latestRelease: undefined, 26 | nextRelease: { 27 | version: '1.2.3', 28 | tag: 'v1.2.3', 29 | publishedAt: new Date('20 Apr 2022 12:00:000 GMT'), 30 | }, 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /src/utils/__test__/createReleaseComment.test.ts: -------------------------------------------------------------------------------- 1 | import { createReleaseComment } from '../createReleaseComment' 2 | import { testEnvironment } from '../../../test/env' 3 | import { mockRepo } from '../../../test/fixtures' 4 | import { createContext } from '../createContext' 5 | 6 | const { setup, reset, cleanup, createRepository } = testEnvironment({ 7 | fileSystemPath: 'create-release-comment', 8 | }) 9 | 10 | beforeAll(async () => { 11 | await setup() 12 | }) 13 | 14 | afterEach(async () => { 15 | await reset() 16 | }) 17 | 18 | afterAll(async () => { 19 | await cleanup() 20 | }) 21 | 22 | it('creates a release comment out of given release context', async () => { 23 | const repo = await createRepository('release-from-context') 24 | 25 | await repo.fs.create({ 26 | 'package.json': JSON.stringify({ 27 | name: 'my-package', 28 | }), 29 | }) 30 | 31 | const comment = createReleaseComment({ 32 | context: createContext({ 33 | repo: mockRepo(), 34 | nextRelease: { 35 | version: '1.2.3', 36 | publishedAt: new Date(), 37 | }, 38 | }), 39 | releaseUrl: '/releases/1', 40 | }) 41 | 42 | expect(comment).toBe(`## Released: v1.2.3 🎉 43 | 44 | This has been released in v1.2.3! 45 | 46 | - 📄 [**Release notes**](/releases/1) 47 | - 📦 [npm package](https://www.npmjs.com/package/my-package/v/1.2.3) 48 | 49 | Make sure to always update to the latest version (\`npm i my-package@latest\`) to get the newest features and bug fixes. 50 | 51 | --- 52 | 53 | _Predictable release automation by [@ossjs/release](https://github.com/ossjs/release)_.`) 54 | }) 55 | -------------------------------------------------------------------------------- /src/utils/__test__/execAsync.test.ts: -------------------------------------------------------------------------------- 1 | import { execAsync } from '../execAsync' 2 | 3 | it('resolves with stdout of the executed command', async () => { 4 | expect(await execAsync('echo "hello world"')).toEqual({ 5 | stdout: 'hello world\n', 6 | stderr: '', 7 | }) 8 | }) 9 | 10 | it('rejects if the command exits', async () => { 11 | await expect(execAsync('exit 1')).rejects.toThrow('Command failed: exit 1\n') 12 | }) 13 | 14 | it('rejects if the command fails', async () => { 15 | await expect(execAsync('open foo.txt')).rejects.toThrow( 16 | 'Command failed: open foo.txt', 17 | ) 18 | }) 19 | 20 | it('propagates environmental variables', async () => { 21 | const std = await execAsync(`echo "hello $OBJECT"`, { 22 | env: { 23 | OBJECT: 'world', 24 | }, 25 | }) 26 | 27 | expect(std).toEqual({ 28 | stdout: 'hello world\n', 29 | stderr: '', 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/utils/__test__/formatDate.test.ts: -------------------------------------------------------------------------------- 1 | import { formatDate } from '../formatDate' 2 | 3 | it('formats a given date using "YYYY-MM-DD" mask', () => { 4 | expect(formatDate(new Date(2020, 0, 5))).toBe('2020-01-05') 5 | expect(formatDate(new Date(2020, 11, 15))).toBe('2020-12-15') 6 | }) 7 | -------------------------------------------------------------------------------- /src/utils/__test__/getNextReleaseType.test.ts: -------------------------------------------------------------------------------- 1 | import { mockCommit } from '../../../test/fixtures' 2 | import { getNextReleaseType } from '../getNextReleaseType' 3 | import { parseCommits } from '../git/parseCommits' 4 | 5 | it('returns "major" for a "feat" commit that contains a "BREAKING CHANGE" footnote', async () => { 6 | expect( 7 | getNextReleaseType( 8 | await parseCommits([ 9 | mockCommit({ 10 | subject: 'fix: stuff', 11 | body: 'BREAKING CHANGE: This is a breaking change.', 12 | }), 13 | ]), 14 | ), 15 | ).toBe('major') 16 | }) 17 | 18 | it('returns "major" for "feat" commit with a subject and a "BREAKING CHANGE" footnote', async () => { 19 | expect( 20 | getNextReleaseType( 21 | await parseCommits([ 22 | mockCommit({ 23 | subject: 'feat(parseUrl): support relative urls', 24 | body: 'BREAKING CHANGE: This is a breaking change.', 25 | }), 26 | ]), 27 | ), 28 | ).toBe('major') 29 | }) 30 | 31 | it('returns "major" for commits with a "!" type appendix', async () => { 32 | expect( 33 | getNextReleaseType( 34 | await parseCommits([ 35 | mockCommit({ 36 | subject: 'feat!: some breaking change', 37 | }), 38 | ]), 39 | ), 40 | ).toBe('major') 41 | 42 | expect( 43 | getNextReleaseType( 44 | await parseCommits([ 45 | mockCommit({ 46 | subject: 'feat(customScope)!: some breaking change', 47 | }), 48 | ]), 49 | ), 50 | ).toBe('major') 51 | 52 | expect( 53 | getNextReleaseType( 54 | await parseCommits([ 55 | mockCommit({ 56 | subject: 'docs!: some breaking change', 57 | }), 58 | ]), 59 | ), 60 | ).toBe('major') 61 | }) 62 | 63 | it('returns "minor" for "feat" commits', async () => { 64 | expect( 65 | getNextReleaseType( 66 | await parseCommits([ 67 | mockCommit({ 68 | subject: 'feat: adds graphql support', 69 | }), 70 | ]), 71 | ), 72 | ).toBe('minor') 73 | 74 | expect( 75 | getNextReleaseType( 76 | await parseCommits([ 77 | mockCommit({ 78 | subject: 'feat: adds graphql support', 79 | }), 80 | mockCommit({ 81 | subject: 'fix: fix stuff', 82 | }), 83 | ]), 84 | ), 85 | ).toBe('minor') 86 | }) 87 | 88 | it('returns "minor" for "feat" commit with a subject', async () => { 89 | expect( 90 | getNextReleaseType( 91 | await parseCommits([ 92 | mockCommit({ 93 | subject: 'feat(parseUrl): support nullable suffi', 94 | }), 95 | ]), 96 | ), 97 | ).toBe('minor') 98 | }) 99 | 100 | it('returns "patch" for "fix" commits', async () => { 101 | expect( 102 | getNextReleaseType( 103 | await parseCommits([ 104 | mockCommit({ 105 | subject: 'fix: return signature', 106 | }), 107 | ]), 108 | ), 109 | ).toBe('patch') 110 | 111 | expect( 112 | getNextReleaseType( 113 | await parseCommits([ 114 | mockCommit({ 115 | subject: 'fix: return signature', 116 | }), 117 | mockCommit({ 118 | subject: 'docs: mention stuff', 119 | }), 120 | ]), 121 | ), 122 | ).toBe('patch') 123 | }) 124 | 125 | it('returns "patch" for "fix" commit with a subject', async () => { 126 | expect( 127 | getNextReleaseType( 128 | await parseCommits([ 129 | mockCommit({ 130 | subject: 'fix(parseUrl): support nullable suffix', 131 | }), 132 | ]), 133 | ), 134 | ).toBe('patch') 135 | }) 136 | 137 | it('returns null when no commits bump the version', async () => { 138 | expect( 139 | getNextReleaseType( 140 | await parseCommits([ 141 | mockCommit({ 142 | subject: 'chore: design better releases', 143 | }), 144 | mockCommit({ 145 | subject: 'docs: mention cli arguments', 146 | }), 147 | ]), 148 | ), 149 | ).toBe(null) 150 | }) 151 | 152 | it('returns "minor" for a breaking change if "prerelease" option is set', async () => { 153 | expect( 154 | getNextReleaseType( 155 | await parseCommits([ 156 | mockCommit({ 157 | subject: 'feat(parseUrl): support relative urls', 158 | body: 'BREAKING CHANGE: This is a breaking change.', 159 | }), 160 | ]), 161 | { prerelease: true }, 162 | ), 163 | ).toBe('minor') 164 | }) 165 | 166 | it('returns "minor" for a minor change if "prerelease" option is set', async () => { 167 | expect( 168 | getNextReleaseType( 169 | await parseCommits([ 170 | mockCommit({ 171 | subject: 'feat: minor change', 172 | }), 173 | ]), 174 | { prerelease: true }, 175 | ), 176 | ).toBe('minor') 177 | }) 178 | 179 | it('returns "patch" for a patch change if "prerelease" option is set', async () => { 180 | expect( 181 | getNextReleaseType( 182 | await parseCommits([ 183 | mockCommit({ 184 | subject: 'fix: some fixes', 185 | }), 186 | ]), 187 | { prerelease: true }, 188 | ), 189 | ).toBe('patch') 190 | }) 191 | -------------------------------------------------------------------------------- /src/utils/__test__/getNextVersion.test.ts: -------------------------------------------------------------------------------- 1 | import { getNextVersion } from '../getNextVersion' 2 | 3 | it('returns the correct patch next version', () => { 4 | expect(getNextVersion('0.0.0', 'patch')).toBe('0.0.1') 5 | expect(getNextVersion('0.1.0', 'patch')).toBe('0.1.1') 6 | expect(getNextVersion('1.1.0', 'patch')).toBe('1.1.1') 7 | }) 8 | 9 | it('returns the correct minor next version', () => { 10 | expect(getNextVersion('0.0.0', 'minor')).toBe('0.1.0') 11 | expect(getNextVersion('0.2.0', 'minor')).toBe('0.3.0') 12 | expect(getNextVersion('1.0.0', 'minor')).toBe('1.1.0') 13 | }) 14 | 15 | it('returns the correct major next version', () => { 16 | expect(getNextVersion('0.0.0', 'major')).toBe('1.0.0') 17 | expect(getNextVersion('0.1.0', 'major')).toBe('1.0.0') 18 | expect(getNextVersion('1.0.0', 'major')).toBe('2.0.0') 19 | }) 20 | -------------------------------------------------------------------------------- /src/utils/bumpPackageJson.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import { readPackageJson } from './readPackageJson' 3 | import { writePackageJson } from './writePackageJson' 4 | 5 | export function bumpPackageJson(version: string): void { 6 | const packageJson = readPackageJson() 7 | packageJson.version = version 8 | 9 | writePackageJson(packageJson) 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/createContext.ts: -------------------------------------------------------------------------------- 1 | import type { GitInfo } from './git/getInfo' 2 | import type { TagPointer } from './git/getTag' 3 | 4 | export interface ReleaseContext { 5 | repo: GitInfo 6 | latestRelease?: TagPointer 7 | nextRelease: { 8 | version: string 9 | readonly tag: string 10 | publishedAt: Date 11 | } 12 | } 13 | 14 | export interface ReleaseContextInput { 15 | repo: GitInfo 16 | latestRelease?: TagPointer 17 | nextRelease: { 18 | version: string 19 | publishedAt: Date 20 | } 21 | } 22 | 23 | export function createContext(input: ReleaseContextInput): ReleaseContext { 24 | const context: ReleaseContext = { 25 | repo: input.repo, 26 | latestRelease: input.latestRelease || undefined, 27 | nextRelease: { 28 | ...input.nextRelease, 29 | tag: null as any, 30 | }, 31 | } 32 | 33 | Object.defineProperty(context.nextRelease, 'tag', { 34 | get() { 35 | return `v${context.nextRelease.version}` 36 | }, 37 | }) 38 | 39 | return context 40 | } 41 | -------------------------------------------------------------------------------- /src/utils/createReleaseComment.ts: -------------------------------------------------------------------------------- 1 | import type { ReleaseContext } from './createContext' 2 | import { readPackageJson } from './readPackageJson' 3 | 4 | export interface ReleaseCommentInput { 5 | context: ReleaseContext 6 | releaseUrl: string 7 | } 8 | 9 | export function createReleaseComment(input: ReleaseCommentInput): string { 10 | const { context, releaseUrl } = input 11 | const packageJson = readPackageJson() 12 | 13 | return `## Released: ${context.nextRelease.tag} 🎉 14 | 15 | This has been released in ${context.nextRelease.tag}! 16 | 17 | - 📄 [**Release notes**](${releaseUrl}) 18 | - 📦 [npm package](https://www.npmjs.com/package/${packageJson.name}/v/${context.nextRelease.version}) 19 | 20 | Make sure to always update to the latest version (\`npm i ${packageJson.name}@latest\`) to get the newest features and bug fixes. 21 | 22 | --- 23 | 24 | _Predictable release automation by [@ossjs/release](https://github.com/ossjs/release)_.` 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/env.ts: -------------------------------------------------------------------------------- 1 | import { invariant } from 'outvariant' 2 | import { validateAccessToken } from './github/validateAccessToken' 3 | 4 | export async function demandGitHubToken(): Promise { 5 | const { GITHUB_TOKEN } = process.env 6 | 7 | invariant( 8 | GITHUB_TOKEN, 9 | 'Failed to publish the package: the "GITHUB_TOKEN" environment variable is not provided.', 10 | ) 11 | 12 | await validateAccessToken(GITHUB_TOKEN) 13 | } 14 | 15 | export async function demandNpmToken(): Promise { 16 | const { NODE_AUTH_TOKEN, NPM_AUTH_TOKEN } = process.env 17 | 18 | invariant( 19 | NODE_AUTH_TOKEN || NPM_AUTH_TOKEN, 20 | 'Failed to publish the package: neither "NODE_AUTH_TOKEN" nor "NPM_AUTH_TOKEN" environment variables were provided.', 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/execAsync.ts: -------------------------------------------------------------------------------- 1 | import { DeferredPromise } from '@open-draft/deferred-promise' 2 | import { type ChildProcess, type ExecOptions, exec } from 'child_process' 3 | 4 | export type ExecAsyncFn = { 5 | ( 6 | command: string, 7 | options?: ExecOptions, 8 | ): DeferredPromiseWithIo 9 | 10 | mockContext(options: ExecOptions): void 11 | restoreContext(): void 12 | contextOptions: ExecOptions 13 | } 14 | 15 | interface DeferredPromiseWithIo extends DeferredPromise { 16 | io: ChildProcess 17 | } 18 | 19 | export interface ExecAsyncPromisePayload { 20 | stdout: string 21 | stderr: string 22 | } 23 | 24 | const DEFAULT_CONTEXT: Partial = { 25 | cwd: process.cwd(), 26 | } 27 | 28 | export const execAsync = ((command, options = {}) => { 29 | const commandPromise = new DeferredPromise<{ 30 | stdout: string 31 | stderr: string 32 | }>() 33 | 34 | const io = exec( 35 | command, 36 | { 37 | ...execAsync.contextOptions, 38 | ...options, 39 | }, 40 | (error, stdout, stderr) => { 41 | if (error) { 42 | return commandPromise.reject(error) 43 | } 44 | 45 | commandPromise.resolve({ 46 | stdout, 47 | stderr, 48 | }) 49 | }, 50 | ) 51 | 52 | // Set the reference to the spawned child process 53 | // on the promise so the consumer can either await 54 | // the entire command or tap into child process 55 | // and handle it manually (e.g. forward stdio). 56 | Reflect.set(commandPromise, 'io', io) 57 | 58 | return commandPromise 59 | }) 60 | 61 | execAsync.mockContext = (options) => { 62 | execAsync.contextOptions = options 63 | } 64 | 65 | execAsync.restoreContext = () => { 66 | execAsync.contextOptions = DEFAULT_CONTEXT 67 | } 68 | 69 | execAsync.restoreContext() 70 | -------------------------------------------------------------------------------- /src/utils/formatDate.ts: -------------------------------------------------------------------------------- 1 | export function formatDate(date: Date): string { 2 | const year = date.getFullYear() 3 | const month = (date.getMonth() + 1).toString().padStart(2, '0') 4 | const day = date.getDate().toString().padStart(2, '0') 5 | 6 | return `${year}-${month}-${day}` 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/getConfig.ts: -------------------------------------------------------------------------------- 1 | import { invariant } from 'outvariant' 2 | import * as path from 'path' 3 | 4 | export interface Config { 5 | profiles: Array 6 | } 7 | 8 | export interface ReleaseProfile { 9 | name: string 10 | 11 | /** 12 | * The publish script command. 13 | * @example npm publish $NEXT_VERSION 14 | * @example pnpm publish --no-git-checks 15 | */ 16 | use: string 17 | 18 | /** 19 | * Indicate a pre-release version. 20 | * Treat breaking changes as minor release versions. 21 | */ 22 | prerelease?: boolean 23 | } 24 | 25 | export function getConfig(): Config { 26 | const configPath = path.resolve(process.cwd(), 'release.config.json') 27 | const config = require(configPath) 28 | validateConfig(config) 29 | 30 | return config 31 | } 32 | 33 | function validateConfig(config: Config): void { 34 | invariant( 35 | Array.isArray(config.profiles), 36 | 'Failed to parse Release configuration: expected a root-level "tags" property to be an array but got %j', 37 | config.profiles, 38 | ) 39 | 40 | invariant( 41 | config.profiles.length > 0, 42 | 'Failed to parse Release configuration: expected at least one profile to be defined', 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/utils/getNextReleaseType.ts: -------------------------------------------------------------------------------- 1 | import * as semver from 'semver' 2 | import { ParsedCommitWithHash } from './git/parseCommits' 3 | 4 | interface GetNextReleaseTypeOptions { 5 | prerelease?: boolean 6 | } 7 | 8 | /** 9 | * Returns true if the given parsed commit represents a breaking change. 10 | * @see https://www.conventionalcommits.org/en/v1.0.0/#summary 11 | */ 12 | export function isBreakingChange(commit: ParsedCommitWithHash): boolean { 13 | if (commit.typeAppendix === '!') { 14 | return true 15 | } 16 | 17 | if (commit.footer && commit.footer.includes('BREAKING CHANGE:')) { 18 | return true 19 | } 20 | 21 | return false 22 | } 23 | 24 | export function getNextReleaseType( 25 | commits: ParsedCommitWithHash[], 26 | options?: GetNextReleaseTypeOptions, 27 | ): semver.ReleaseType | null { 28 | const ranges: ['minor' | null, 'patch' | null] = [null, null] 29 | 30 | for (const commit of commits) { 31 | if (isBreakingChange(commit)) { 32 | return options?.prerelease ? 'minor' : 'major' 33 | } 34 | 35 | // Respect the parsed "type" from the "conventional-commits-parser". 36 | switch (commit.type) { 37 | case 'feat': { 38 | ranges[0] = 'minor' 39 | break 40 | } 41 | 42 | case 'fix': { 43 | ranges[1] = 'patch' 44 | break 45 | } 46 | } 47 | } 48 | 49 | /** 50 | * @fixme Commit messages can also append "!" to the scope 51 | * to indicate that the commit is a breaking change. 52 | * @see https://www.conventionalcommits.org/en/v1.0.0/#summary 53 | * 54 | * Unfortunately, "conventional-commits-parser" does not support that. 55 | */ 56 | 57 | return ranges[0] || ranges[1] 58 | } 59 | -------------------------------------------------------------------------------- /src/utils/getNextVersion.ts: -------------------------------------------------------------------------------- 1 | import { invariant } from 'outvariant' 2 | import * as semver from 'semver' 3 | 4 | export function getNextVersion( 5 | previousVersion: string, 6 | releaseType: semver.ReleaseType, 7 | ): string { 8 | const nextVersion = semver.inc(previousVersion, releaseType) 9 | 10 | invariant( 11 | nextVersion, 12 | 'Failed to calculate the next version from "%s" using release type "%s"', 13 | previousVersion, 14 | releaseType, 15 | ) 16 | 17 | return nextVersion 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/git/__test__/createTag.test.ts: -------------------------------------------------------------------------------- 1 | import { testEnvironment } from '../../../../test/env' 2 | import { execAsync } from '../../execAsync' 3 | import { createTag } from '../createTag' 4 | 5 | const { setup, reset, cleanup, createRepository } = testEnvironment({ 6 | fileSystemPath: 'create-tag', 7 | }) 8 | 9 | beforeAll(async () => { 10 | await setup() 11 | }) 12 | 13 | afterEach(async () => { 14 | await reset() 15 | }) 16 | afterAll(async () => { 17 | await cleanup() 18 | }) 19 | 20 | it('creates a new tag', async () => { 21 | await createRepository('new-tag') 22 | expect(await createTag('1.0.0')).toBe('1.0.0') 23 | }) 24 | 25 | it('does not create a tag when it already exists', async () => { 26 | await createRepository('existing-tag') 27 | 28 | jest.spyOn(console, 'error').mockImplementation() 29 | await execAsync('git tag 1.0.0') 30 | await expect(createTag('1.0.0')).rejects.toThrow( 31 | `fatal: tag '1.0.0' already exists`, 32 | ) 33 | }) 34 | -------------------------------------------------------------------------------- /src/utils/git/__test__/getCommits.test.ts: -------------------------------------------------------------------------------- 1 | import { getCommits } from '../getCommits' 2 | import { testEnvironment } from '../../../../test/env' 3 | import { execAsync } from '../../execAsync' 4 | 5 | const { setup, reset, cleanup, createRepository } = testEnvironment({ 6 | fileSystemPath: 'get-commits', 7 | }) 8 | 9 | beforeAll(async () => { 10 | await setup() 11 | }) 12 | 13 | afterEach(async () => { 14 | await reset() 15 | }) 16 | 17 | afterAll(async () => { 18 | await cleanup() 19 | }) 20 | 21 | it('returns commits since the given commit', async () => { 22 | await createRepository('new-commits-since-latest') 23 | 24 | await execAsync(`git commit -m 'one' --allow-empty`) 25 | await execAsync(`git commit -m 'two' --allow-empty`) 26 | const secondCommitHash = await execAsync( 27 | `git log --pretty=format:'%H' -n 1`, 28 | ).then(({ stdout }) => stdout) 29 | await execAsync(`git commit -m 'three' --allow-empty`) 30 | const thirdCommitHash = await execAsync( 31 | `git log --pretty=format:'%H' -n 1`, 32 | ).then(({ stdout }) => stdout) 33 | 34 | expect(await getCommits({ since: secondCommitHash.trim() })).toEqual([ 35 | expect.objectContaining({ 36 | subject: 'three', 37 | hash: thirdCommitHash.trim(), 38 | }), 39 | ]) 40 | }) 41 | 42 | it('returns commits until the given commit', async () => { 43 | await createRepository('commits-until-given') 44 | 45 | await execAsync(`git commit -m 'one' --allow-empty`) 46 | const oneCommitHash = await execAsync(`git log --pretty=format:%H -n 1`).then( 47 | ({ stdout }) => stdout, 48 | ) 49 | await execAsync(`git commit -m 'two' --allow-empty`) 50 | const secondCommitHash = await execAsync( 51 | `git log --pretty=format:%H -n 1`, 52 | ).then(({ stdout }) => stdout) 53 | await execAsync(`git commit -m 'three' --allow-empty`) 54 | 55 | expect(await getCommits({ until: secondCommitHash.trim() })).toEqual([ 56 | expect.objectContaining({ 57 | subject: 'one', 58 | hash: oneCommitHash.trim(), 59 | }), 60 | expect.objectContaining({ 61 | subject: 'chore(test): initial commit', 62 | hash: expect.any(String), 63 | }), 64 | ]) 65 | }) 66 | 67 | it('returns commits within the range', async () => { 68 | await createRepository('commits-without-range') 69 | 70 | await execAsync(`git commit -m 'one' --allow-empty`) 71 | await execAsync(`git commit -m 'two' --allow-empty`) 72 | const secondCommitHash = await execAsync( 73 | `git log --pretty=format:'%H' -n 1`, 74 | ).then(({ stdout }) => stdout) 75 | await execAsync(`git commit -m 'three' --allow-empty`) 76 | const thirdCommitHash = await execAsync( 77 | `git log --pretty=format:'%H' -n 1`, 78 | ).then(({ stdout }) => stdout) 79 | await execAsync(`git commit -m 'four' --allow-empty`) 80 | const fourthCommitHash = await execAsync( 81 | `git log --pretty=format:'%H' -n 1`, 82 | ).then(({ stdout }) => stdout) 83 | 84 | expect( 85 | await getCommits({ 86 | since: secondCommitHash.trim(), 87 | until: fourthCommitHash.trim(), 88 | }), 89 | ).toEqual([ 90 | expect.objectContaining({ 91 | subject: 'four', 92 | hash: fourthCommitHash.trim(), 93 | }), 94 | expect.objectContaining({ 95 | subject: 'three', 96 | hash: thirdCommitHash.trim(), 97 | }), 98 | ]) 99 | }) 100 | 101 | it('returns all commits when called without any range', async () => { 102 | await createRepository('all-commits') 103 | 104 | await execAsync(`git commit -m 'one' --allow-empty`) 105 | const firstCommitHash = await execAsync( 106 | `git log --pretty=format:'%H' -n 1`, 107 | ).then(({ stdout }) => stdout) 108 | await execAsync(`git commit -m 'two' --allow-empty`) 109 | const secondCommitHash = await execAsync( 110 | `git log --pretty=format:'%H' -n 1`, 111 | ).then(({ stdout }) => stdout) 112 | await execAsync(`git commit -m 'three' --allow-empty`) 113 | const thirdCommitHash = await execAsync( 114 | `git log --pretty=format:'%H' -n 1`, 115 | ).then(({ stdout }) => stdout) 116 | 117 | expect(await getCommits()).toEqual([ 118 | expect.objectContaining({ 119 | subject: 'three', 120 | hash: thirdCommitHash.trim(), 121 | }), 122 | expect.objectContaining({ 123 | subject: 'two', 124 | hash: secondCommitHash.trim(), 125 | }), 126 | expect.objectContaining({ 127 | subject: 'one', 128 | hash: firstCommitHash.trim(), 129 | }), 130 | // This is the initial commit created by "initGit". 131 | expect.objectContaining({ 132 | subject: 'chore(test): initial commit', 133 | }), 134 | ]) 135 | }) 136 | -------------------------------------------------------------------------------- /src/utils/git/__test__/getCurrentBranch.test.ts: -------------------------------------------------------------------------------- 1 | import { testEnvironment } from '../../../../test/env' 2 | import { execAsync } from '../../execAsync' 3 | import { getCurrentBranch } from '../getCurrentBranch' 4 | 5 | const { setup, reset, cleanup, createRepository } = testEnvironment({ 6 | fileSystemPath: 'get-current-branch', 7 | }) 8 | 9 | beforeAll(async () => { 10 | await setup() 11 | }) 12 | 13 | afterEach(async () => { 14 | await reset() 15 | }) 16 | 17 | afterAll(async () => { 18 | await cleanup() 19 | }) 20 | 21 | it('returns the name of the current branch', async () => { 22 | await createRepository('current-branch') 23 | expect(await getCurrentBranch()).toBe('main') 24 | }) 25 | 26 | it('returns the name of the feature branch', async () => { 27 | await createRepository('feature-branch') 28 | await execAsync('git checkout -b feat/custom') 29 | 30 | expect(await getCurrentBranch()).toBe('feat/custom') 31 | }) 32 | -------------------------------------------------------------------------------- /src/utils/git/__test__/getInfo.test.ts: -------------------------------------------------------------------------------- 1 | import { createTeardown } from 'fs-teardown' 2 | import { execAsync } from '../../execAsync' 3 | import { getInfo, GitInfo } from '../getInfo' 4 | 5 | const fsMock = createTeardown({ 6 | rootDir: 'tarm/get-tags', 7 | }) 8 | 9 | beforeAll(async () => { 10 | await fsMock.prepare() 11 | execAsync.mockContext({ 12 | cwd: fsMock.resolve(), 13 | }) 14 | }) 15 | 16 | beforeEach(async () => { 17 | await fsMock.reset() 18 | jest.restoreAllMocks() 19 | }) 20 | 21 | afterAll(async () => { 22 | await fsMock.cleanup() 23 | execAsync.restoreContext() 24 | }) 25 | 26 | it('parses SSH remote url', async () => { 27 | await execAsync('git init') 28 | await execAsync('git remote add origin git@github.com:octocat/test.git') 29 | 30 | expect(await getInfo()).toEqual({ 31 | remote: 'git@github.com:octocat/test.git', 32 | owner: 'octocat', 33 | name: 'test', 34 | url: 'https://github.com/octocat/test/', 35 | }) 36 | }) 37 | 38 | it('parses HTTPS remote url', async () => { 39 | await execAsync('git init') 40 | await execAsync('git remote add origin https://github.com/octocat/test.git') 41 | 42 | expect(await getInfo()).toEqual({ 43 | remote: 'https://github.com/octocat/test.git', 44 | owner: 'octocat', 45 | name: 'test', 46 | url: 'https://github.com/octocat/test/', 47 | }) 48 | }) 49 | 50 | it('parses HTTPS remote url without the ".git" suffix', async () => { 51 | await execAsync('git init') 52 | await execAsync('git remote add origin https://github.com/octocat/test') 53 | 54 | expect(await getInfo()).toEqual({ 55 | remote: 'https://github.com/octocat/test', 56 | owner: 'octocat', 57 | name: 'test', 58 | url: 'https://github.com/octocat/test/', 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /src/utils/git/__test__/getTags.test.ts: -------------------------------------------------------------------------------- 1 | import { testEnvironment } from '../../../../test/env' 2 | import { execAsync } from '../../execAsync' 3 | import { getTags } from '../getTags' 4 | 5 | const { setup, reset, cleanup, createRepository } = testEnvironment({ 6 | fileSystemPath: 'get-tags', 7 | }) 8 | 9 | beforeAll(async () => { 10 | await setup() 11 | }) 12 | 13 | afterEach(async () => { 14 | await reset() 15 | }) 16 | 17 | afterAll(async () => { 18 | await cleanup() 19 | }) 20 | 21 | it('returns empty array when there are no tags', async () => { 22 | await createRepository('no-tags') 23 | expect(await getTags()).toEqual([]) 24 | }) 25 | 26 | it('returns a single existing tag', async () => { 27 | await createRepository('single-tag') 28 | await execAsync('git tag 1.2.3') 29 | 30 | expect(await getTags()).toEqual(['1.2.3']) 31 | }) 32 | 33 | it('returns mutliple existing tags', async () => { 34 | await createRepository('multiple-tags') 35 | 36 | await execAsync('git tag 1.0.0') 37 | await execAsync('git tag 1.0.5') 38 | await execAsync('git tag 2.3.1') 39 | 40 | expect(await getTags()).toEqual(['1.0.0', '1.0.5', '2.3.1']) 41 | }) 42 | -------------------------------------------------------------------------------- /src/utils/git/__test__/parseCommits.test.ts: -------------------------------------------------------------------------------- 1 | import { mockCommit } from '../../../../test/fixtures' 2 | import { parseCommits } from '../parseCommits' 3 | 4 | it('parses commits with the "!" type appendix', async () => { 5 | expect( 6 | await parseCommits([ 7 | mockCommit({ 8 | subject: 'feat!: some breaking change', 9 | }), 10 | mockCommit({ 11 | subject: 'fix(myScope)!: another change', 12 | body: 'commit body', 13 | }), 14 | ]), 15 | ).toEqual([ 16 | { 17 | hash: '', 18 | type: 'feat', 19 | typeAppendix: '!', 20 | header: 'feat: some breaking change', 21 | subject: 'some breaking change', 22 | body: null, 23 | footer: null, 24 | merge: null, 25 | revert: null, 26 | scope: null, 27 | notes: [], 28 | mentions: [], 29 | references: [], 30 | }, 31 | { 32 | hash: '', 33 | type: 'fix', 34 | typeAppendix: '!', 35 | header: 'fix(myScope): another change', 36 | subject: 'another change', 37 | body: 'commit body', 38 | footer: null, 39 | merge: null, 40 | revert: null, 41 | scope: 'myScope', 42 | notes: [], 43 | mentions: [], 44 | references: [], 45 | }, 46 | ]) 47 | }) 48 | -------------------------------------------------------------------------------- /src/utils/git/commit.ts: -------------------------------------------------------------------------------- 1 | import type { Commit } from 'git-log-parser' 2 | import { execAsync } from '../execAsync' 3 | import { getCommit } from './getCommit' 4 | import { parseCommits, ParsedCommitWithHash } from './parseCommits' 5 | 6 | export interface CommitOptions { 7 | message: string 8 | files?: string[] 9 | allowEmpty?: boolean 10 | date?: Date 11 | } 12 | 13 | export async function commit({ 14 | files, 15 | message, 16 | allowEmpty, 17 | date, 18 | }: CommitOptions): Promise { 19 | if (files) { 20 | await execAsync(`git add ${files.join(' ')}`) 21 | } 22 | 23 | const args: string[] = [ 24 | `-m "${message}"`, 25 | allowEmpty ? '--allow-empty' : '', 26 | date ? `--date "${date.toISOString()}"` : '', 27 | ] 28 | 29 | await execAsync(`git commit ${args.join(' ')}`) 30 | const hash = await execAsync('git log --pretty=format:%H -n 1').then( 31 | ({ stdout }) => stdout, 32 | ) 33 | const commit = (await getCommit(hash)) as Commit 34 | 35 | const [commitInfo] = await parseCommits([commit]) 36 | return commitInfo 37 | } 38 | -------------------------------------------------------------------------------- /src/utils/git/createTag.ts: -------------------------------------------------------------------------------- 1 | import { execAsync } from '../execAsync' 2 | 3 | export async function createTag(tag: string): Promise { 4 | await execAsync(`git tag ${tag}`) 5 | const latestTag = await execAsync(`git describe --tags --abbrev=0`).then( 6 | ({ stdout }) => stdout, 7 | ) 8 | 9 | return latestTag.trim() 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/git/getCommit.ts: -------------------------------------------------------------------------------- 1 | import * as getStream from 'get-stream' 2 | import gitLogParser, { Commit } from 'git-log-parser' 3 | import { execAsync } from '../execAsync' 4 | 5 | export async function getCommit(hash: string): Promise { 6 | Object.assign(gitLogParser.fields, { 7 | hash: 'H', 8 | message: 'B', 9 | }) 10 | 11 | const result = await getStream.array( 12 | gitLogParser.parse( 13 | { 14 | _: hash, 15 | n: 1, 16 | }, 17 | { 18 | // Respect the global working directory so this command 19 | // parses commits on test repositories during tests. 20 | cwd: execAsync.contextOptions.cwd, 21 | }, 22 | ), 23 | ) 24 | 25 | return result?.[0] 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/git/getCommits.ts: -------------------------------------------------------------------------------- 1 | import * as getStream from 'get-stream' 2 | import * as gitLogParser from 'git-log-parser' 3 | import { execAsync } from '../execAsync' 4 | 5 | interface GetCommitsOptions { 6 | since?: string 7 | until?: string 8 | } 9 | 10 | /** 11 | * Return the list of parsed commits within the given range. 12 | */ 13 | export function getCommits({ 14 | since, 15 | until = 'HEAD', 16 | }: GetCommitsOptions = {}): Promise { 17 | Object.assign(gitLogParser.fields, { 18 | hash: 'H', 19 | message: 'B', 20 | }) 21 | 22 | const range: string = since ? `${since}..${until}` : until 23 | 24 | // When only the "until" commit is specified, skip the first commit. 25 | const skip = range === until && until !== 'HEAD' ? 1 : undefined 26 | 27 | return getStream.array( 28 | gitLogParser.parse( 29 | { 30 | _: range, 31 | skip, 32 | }, 33 | { 34 | cwd: execAsync.contextOptions.cwd, 35 | }, 36 | ), 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/git/getCurrentBranch.ts: -------------------------------------------------------------------------------- 1 | import { execAsync } from '../execAsync' 2 | 3 | export async function getCurrentBranch(): Promise { 4 | const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD') 5 | return stdout.trim() 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/git/getInfo.ts: -------------------------------------------------------------------------------- 1 | import { invariant } from 'outvariant' 2 | import { execAsync } from '../execAsync' 3 | 4 | export interface GitInfo { 5 | owner: string 6 | name: string 7 | remote: string 8 | url: string 9 | } 10 | 11 | export async function getInfo(): Promise { 12 | const remote = await execAsync(`git config --get remote.origin.url`).then( 13 | ({ stdout }) => stdout.trim(), 14 | ) 15 | const [owner, name] = parseOriginUrl(remote) 16 | 17 | invariant( 18 | remote, 19 | 'Failed to extract Git info: expected an origin URL but got %s.', 20 | remote, 21 | ) 22 | invariant( 23 | owner, 24 | 'Failed to extract Git info: expected repository owner but got %s.', 25 | owner, 26 | ) 27 | invariant( 28 | name, 29 | 'Failed to extract Git info: expected repository name but got %s.', 30 | name, 31 | ) 32 | 33 | return { 34 | remote, 35 | owner, 36 | name, 37 | url: new URL(`https://github.com/${owner}/${name}/`).href, 38 | } 39 | } 40 | 41 | export function parseOriginUrl(origin: string): [string, string] { 42 | if (origin.startsWith('git@')) { 43 | const match = /:(.+?)\/(.+)\.git$/g.exec(origin) 44 | 45 | invariant( 46 | match, 47 | 'Failed to parse origin URL "%s": invalid URL structure.', 48 | origin, 49 | ) 50 | 51 | return [match[1], match[2]] 52 | } 53 | 54 | if (/^http(s)?:\/\//.test(origin)) { 55 | const url = new URL(origin) 56 | const match = /\/(.+?)\/(.+?)(\.git)?$/.exec(url.pathname) 57 | 58 | invariant( 59 | match, 60 | 'Failed to parse origin URL "%s": invalid URL structure.', 61 | origin, 62 | ) 63 | 64 | return [match[1], match[2]] 65 | } 66 | 67 | invariant( 68 | false, 69 | 'Failed to extract repository owner/name: given origin URL "%s" is of unknown scheme (Git/HTTP/HTTPS).', 70 | origin, 71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /src/utils/git/getLatestRelease.ts: -------------------------------------------------------------------------------- 1 | import * as semver from 'semver' 2 | import { getTag, TagPointer } from './getTag' 3 | 4 | export function byReleaseVersion(left: string, right: string): number { 5 | return semver.rcompare(left, right) 6 | } 7 | 8 | export async function getLatestRelease( 9 | tags: string[], 10 | ): Promise { 11 | const allTags = tags.filter((tag) => { 12 | return semver.valid(tag) 13 | }).sort(byReleaseVersion) 14 | const [latestTag] = allTags 15 | 16 | if (!latestTag) { 17 | return 18 | } 19 | 20 | return getTag(latestTag) 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/git/getTag.ts: -------------------------------------------------------------------------------- 1 | import { until } from '@open-draft/until' 2 | import { execAsync } from '../execAsync' 3 | 4 | export interface TagPointer { 5 | tag: string 6 | hash: string 7 | } 8 | 9 | /** 10 | * Get tag pointer by tag name. 11 | */ 12 | export async function getTag(tag: string): Promise { 13 | const commitHashOut = await until(() => { 14 | return execAsync(`git rev-list -n 1 ${tag}`) 15 | }) 16 | 17 | // Gracefully handle the errors. 18 | // Getting commit hash by tag name can fail given an unknown tag. 19 | if (commitHashOut.error) { 20 | return undefined 21 | } 22 | 23 | return { 24 | tag, 25 | hash: commitHashOut.data.stdout.trim(), 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/git/getTags.ts: -------------------------------------------------------------------------------- 1 | import { execAsync } from '../execAsync' 2 | 3 | /** 4 | * Return the list of tags present on the current Git branch. 5 | */ 6 | export async function getTags(): Promise> { 7 | const allTags = await execAsync('git tag --merged').then( 8 | ({ stdout }) => stdout, 9 | ) 10 | 11 | return allTags.split('\n').filter(Boolean) 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/git/parseCommits.ts: -------------------------------------------------------------------------------- 1 | import { PassThrough } from 'node:stream' 2 | import { invariant } from 'outvariant' 3 | import { DeferredPromise } from '@open-draft/deferred-promise' 4 | import type { Commit } from 'git-log-parser' 5 | import parseCommit from 'conventional-commits-parser' 6 | import type { Commit as ParsedCommit } from 'conventional-commits-parser' 7 | 8 | export type ParsedCommitWithHash = ParsedCommit & { 9 | hash: string 10 | typeAppendix?: '!' | (string & {}) 11 | } 12 | 13 | const COMMIT_HEADER_APPENDIX_REGEXP = /^(.+)(!)(:)/g 14 | 15 | export async function parseCommits( 16 | commits: Commit[], 17 | ): Promise> { 18 | const through = new PassThrough() 19 | const commitMap: Map = new Map() 20 | 21 | for (const commit of commits) { 22 | commitMap.set(commit.subject, commit) 23 | const message = joinCommit(commit.subject, commit.body) 24 | through.write(message, 'utf8') 25 | } 26 | 27 | through.end() 28 | 29 | const commitParser = parseCommit() 30 | 31 | const parsingStreamPromise = new DeferredPromise< 32 | Array 33 | >() 34 | parsingStreamPromise.finally(() => { 35 | through.destroy() 36 | }) 37 | 38 | const parsedCommits: Array = [] 39 | 40 | through 41 | .pipe(commitParser) 42 | .on('error', (error) => parsingStreamPromise.reject(error)) 43 | .on('data', (parsedCommit: ParsedCommit) => { 44 | let resolvedParsingResult = parsedCommit 45 | 46 | if (!parsedCommit.header) { 47 | return 48 | } 49 | 50 | let typeAppendix 51 | 52 | if (COMMIT_HEADER_APPENDIX_REGEXP.test(parsedCommit.header)) { 53 | const headerWithoutAppendix = parsedCommit.header.replace( 54 | COMMIT_HEADER_APPENDIX_REGEXP, 55 | '$1$3', 56 | ) 57 | resolvedParsingResult = parseCommit.sync( 58 | joinCommit(headerWithoutAppendix, parsedCommit.body), 59 | ) 60 | typeAppendix = '!' 61 | } 62 | 63 | const originalCommit = commitMap.get(parsedCommit.header) 64 | 65 | invariant( 66 | originalCommit, 67 | 'Failed to parse commit "%s": no original commit found associated with header', 68 | parsedCommit.header, 69 | ) 70 | 71 | const commit: ParsedCommitWithHash = Object.assign( 72 | {}, 73 | resolvedParsingResult, 74 | { 75 | hash: originalCommit.hash, 76 | typeAppendix, 77 | }, 78 | ) 79 | parsedCommits.push(commit) 80 | }) 81 | .on('end', () => parsingStreamPromise.resolve(parsedCommits)) 82 | 83 | return parsingStreamPromise 84 | } 85 | 86 | function joinCommit(subject: string, body: string | null | undefined) { 87 | return [subject, body].filter(Boolean).join('\n') 88 | } 89 | -------------------------------------------------------------------------------- /src/utils/git/push.ts: -------------------------------------------------------------------------------- 1 | import { execAsync } from '../execAsync' 2 | 3 | export async function push(): Promise { 4 | await execAsync(`git push`) 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/github/__test__/createGitHubRelease.test.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw' 2 | import { DeferredPromise } from '@open-draft/deferred-promise' 3 | import { testEnvironment } from '../../../../test/env' 4 | import { mockRepo } from '../../../../test/fixtures' 5 | import type { GitHubRelease } from '../getGitHubRelease' 6 | import { createGitHubRelease } from '../createGitHubRelease' 7 | 8 | const { setup, reset, cleanup, api } = testEnvironment({ 9 | fileSystemPath: 'create-github-release', 10 | }) 11 | 12 | beforeAll(async () => { 13 | await setup() 14 | }) 15 | 16 | afterEach(async () => { 17 | await reset() 18 | }) 19 | 20 | afterAll(async () => { 21 | await cleanup() 22 | }) 23 | 24 | it('marks the release as non-latest if there is a higher version released on GitHub', async () => { 25 | const repo = mockRepo() 26 | const requestBodyPromise = new DeferredPromise() 27 | api.use( 28 | rest.get( 29 | `https://api.github.com/repos/:owner/:name/releases/latest`, 30 | (req, res, ctx) => { 31 | return res( 32 | // Set the latest GitHub release as v2.0.0. 33 | ctx.json({ 34 | tag_name: 'v2.0.0', 35 | html_url: '/v2.0.0', 36 | }), 37 | ) 38 | }, 39 | ), 40 | rest.post( 41 | `https://api.github.com/repos/:owner/:name/releases`, 42 | (req, res, ctx) => { 43 | requestBodyPromise.resolve(req.json()) 44 | return res( 45 | ctx.status(201), 46 | ctx.json({ 47 | tag_name: 'v1.1.1', 48 | html_url: '/v1.1.1', 49 | }), 50 | ) 51 | }, 52 | ), 53 | ) 54 | 55 | // Try to release a backport version for v1.0.0. 56 | const notes = '# Release notes' 57 | const githubRelease = await createGitHubRelease( 58 | { 59 | repo, 60 | nextRelease: { 61 | version: '1.1.1', 62 | tag: 'v1.1.1', 63 | publishedAt: new Date(), 64 | }, 65 | }, 66 | notes, 67 | ) 68 | expect(githubRelease).toHaveProperty('html_url', '/v1.1.1') 69 | 70 | const requestBody = await requestBodyPromise 71 | expect(requestBody).toEqual({ 72 | tag_name: 'v1.1.1', 73 | name: 'v1.1.1', 74 | body: notes, 75 | // Must set "false" as the value of the "make_latest" property. 76 | make_latest: 'false', 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /src/utils/github/__test__/getCommitAuthors.test.ts: -------------------------------------------------------------------------------- 1 | import { graphql } from 'msw' 2 | import { getCommitAuthors } from '../getCommitAuthors' 3 | import { log } from '../../../logger' 4 | import { mockCommit } from '../../../../test/fixtures' 5 | import { parseCommits } from '../../git/parseCommits' 6 | import { testEnvironment } from '../../../../test/env' 7 | 8 | const { setup, reset, cleanup, api } = testEnvironment({ 9 | fileSystemPath: 'get-commit-authors', 10 | }) 11 | 12 | beforeAll(async () => { 13 | await setup() 14 | }) 15 | 16 | afterEach(async () => { 17 | await reset() 18 | }) 19 | 20 | afterAll(async () => { 21 | await cleanup() 22 | }) 23 | 24 | it('returns github handle for the pull request author if they are the only contributor', async () => { 25 | api.use( 26 | graphql.query('GetCommitAuthors', (req, res, ctx) => { 27 | return res( 28 | ctx.data({ 29 | repository: { 30 | pullRequest: { 31 | author: { login: 'octocat' }, 32 | commits: { 33 | nodes: [ 34 | { 35 | commit: { 36 | authors: { 37 | nodes: [ 38 | { 39 | user: { login: 'octocat' }, 40 | }, 41 | ], 42 | }, 43 | }, 44 | }, 45 | ], 46 | }, 47 | }, 48 | }, 49 | }), 50 | ) 51 | }), 52 | ) 53 | 54 | const commits = await parseCommits([ 55 | mockCommit({ 56 | subject: 'fix: does things (#1)', 57 | }), 58 | ]) 59 | 60 | const authors = await getCommitAuthors(commits[0]) 61 | 62 | expect(authors).toEqual(new Set(['octocat'])) 63 | }) 64 | 65 | it('returns github handles for all contributors to the release commit', async () => { 66 | api.use( 67 | graphql.query('GetCommitAuthors', (req, res, ctx) => { 68 | return res( 69 | ctx.data({ 70 | repository: { 71 | pullRequest: { 72 | author: { login: 'octocat' }, 73 | commits: { 74 | nodes: [ 75 | { 76 | // Commit by the pull request author. 77 | commit: { 78 | authors: { 79 | nodes: [ 80 | { 81 | user: { login: 'octocat' }, 82 | }, 83 | ], 84 | }, 85 | }, 86 | }, 87 | // Commit by another user in the same pull request. 88 | { 89 | commit: { 90 | authors: { 91 | nodes: [ 92 | { 93 | user: { login: 'john.doe' }, 94 | }, 95 | ], 96 | }, 97 | }, 98 | }, 99 | // Commit authored my multiple users in the same pull request. 100 | { 101 | commit: { 102 | authors: { 103 | nodes: [ 104 | { 105 | user: { login: 'kate' }, 106 | }, 107 | { 108 | user: { login: 'john.doe' }, 109 | }, 110 | ], 111 | }, 112 | }, 113 | }, 114 | ], 115 | }, 116 | }, 117 | }, 118 | }), 119 | ) 120 | }), 121 | ) 122 | const commits = await parseCommits([ 123 | mockCommit({ 124 | subject: 'fix: does things (#1)', 125 | }), 126 | ]) 127 | 128 | const authors = await getCommitAuthors(commits[0]) 129 | 130 | expect(authors).toEqual(new Set(['octocat', 'john.doe', 'kate'])) 131 | }) 132 | 133 | it('returns an empty set for a commit without references', async () => { 134 | const commits = await parseCommits([ 135 | mockCommit({ 136 | subject: 'fix: just a commit', 137 | }), 138 | ]) 139 | 140 | const authors = await getCommitAuthors(commits[0]) 141 | 142 | expect(authors).toEqual(new Set()) 143 | }) 144 | 145 | it('forwards github graphql errors', async () => { 146 | const errors = [{ message: 'one' }, { message: 'two' }] 147 | api.use( 148 | graphql.query('GetCommitAuthors', (req, res, ctx) => { 149 | return res(ctx.errors(errors)) 150 | }), 151 | ) 152 | const commits = await parseCommits([ 153 | mockCommit({ 154 | subject: 'feat: fail fetching of this commit (#5)', 155 | }), 156 | ]) 157 | 158 | await getCommitAuthors(commits[0]) 159 | 160 | expect(log.error).toHaveBeenCalledWith( 161 | `Failed to extract the authors for the issue #5: GitHub API responded with 2 error(s): ${JSON.stringify( 162 | errors, 163 | )}`, 164 | ) 165 | }) 166 | 167 | it('forwards github server errors', async () => { 168 | api.use( 169 | graphql.query('GetCommitAuthors', (req, res, ctx) => { 170 | return res(ctx.status(401)) 171 | }), 172 | ) 173 | const commits = await parseCommits([ 174 | mockCommit({ 175 | subject: 'feat: fail fetching of this commit (#5)', 176 | }), 177 | ]) 178 | 179 | await getCommitAuthors(commits[0]) 180 | 181 | expect(log.error).toHaveBeenCalledWith( 182 | 'Failed to extract the authors for the issue #5: GitHub API responded with 401.', 183 | ) 184 | }) 185 | -------------------------------------------------------------------------------- /src/utils/github/__test__/validateAccessToken.test.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw' 2 | import { api } from '../../../../test/env' 3 | import { 4 | validateAccessToken, 5 | GITHUB_NEW_TOKEN_URL, 6 | } from '../validateAccessToken' 7 | 8 | it('resolves given a token with sufficient permissions', async () => { 9 | api.use( 10 | rest.get('https://api.github.com', (req, res, ctx) => { 11 | return res( 12 | ctx.set('x-oauth-scopes', 'repo, admin:repo_hook, admin:org_hook'), 13 | ) 14 | }), 15 | ) 16 | 17 | await expect(validateAccessToken('TOKEN')).resolves.toBeUndefined() 18 | }) 19 | 20 | it('throws an error given a generic error response from the API', async () => { 21 | api.use( 22 | rest.get('https://api.github.com', (req, res, ctx) => { 23 | return res(ctx.status(500)) 24 | }), 25 | ) 26 | 27 | await expect(validateAccessToken('TOKEN')).rejects.toThrow( 28 | `Failed to verify GitHub token permissions: GitHub API responded with 500 Internal Server Error. Please double-check your "GITHUB_TOKEN" environmental variable and try again.`, 29 | ) 30 | }) 31 | 32 | it('throws an error given an API response with the missing "X-OAuth-Scopes" header', async () => { 33 | api.use( 34 | rest.get('https://api.github.com', (req, res, ctx) => { 35 | return res() 36 | }), 37 | ) 38 | 39 | await expect(validateAccessToken('TOKEN')).rejects.toThrow( 40 | `Failed to verify GitHub token permissions: GitHub API responded with an empty "X-OAuth-Scopes" header.`, 41 | ) 42 | }) 43 | 44 | it('throws an error given access token without the "repo" scope', async () => { 45 | api.use( 46 | rest.get('https://api.github.com', (req, res, ctx) => { 47 | return res(ctx.set('x-oauth-scopes', 'admin:repo_hook, admin:org_hook')) 48 | }), 49 | ) 50 | 51 | await expect(validateAccessToken('TOKEN')).rejects.toThrow( 52 | `Provided "GITHUB_TOKEN" environment variable has insufficient permissions: missing scopes "repo". Please generate a new GitHub personal access token from this URL: ${GITHUB_NEW_TOKEN_URL}`, 53 | ) 54 | }) 55 | 56 | it('throws an error given access token without the "admin:repo_hook" scope', async () => { 57 | api.use( 58 | rest.get('https://api.github.com', (req, res, ctx) => { 59 | return res(ctx.set('x-oauth-scopes', 'repo, admin:org_hook')) 60 | }), 61 | ) 62 | 63 | await expect(validateAccessToken('TOKEN')).rejects.toThrow( 64 | `Provided "GITHUB_TOKEN" environment variable has insufficient permissions: missing scopes "admin:repo_hook". Please generate a new GitHub personal access token from this URL: ${GITHUB_NEW_TOKEN_URL}`, 65 | ) 66 | }) 67 | 68 | it('throws an error given access token without the "admin:org_hook" scope', async () => { 69 | api.use( 70 | rest.get('https://api.github.com', (req, res, ctx) => { 71 | return res(ctx.set('x-oauth-scopes', 'repo, admin:repo_hook')) 72 | }), 73 | ) 74 | 75 | await expect(validateAccessToken('TOKEN')).rejects.toThrow( 76 | `Provided "GITHUB_TOKEN" environment variable has insufficient permissions: missing scopes "admin:org_hook". Please generate a new GitHub personal access token from this URL: ${GITHUB_NEW_TOKEN_URL}`, 77 | ) 78 | }) 79 | 80 | it('throws an error given access token with missing multiple scopes', async () => { 81 | api.use( 82 | rest.get('https://api.github.com', (req, res, ctx) => { 83 | return res(ctx.set('x-oauth-scopes', 'admin:repo_hook')) 84 | }), 85 | ) 86 | 87 | await expect(validateAccessToken('TOKEN')).rejects.toThrow( 88 | `Provided "GITHUB_TOKEN" environment variable has insufficient permissions: missing scopes "repo", "admin:org_hook". Please generate a new GitHub personal access token from this URL: ${GITHUB_NEW_TOKEN_URL}`, 89 | ) 90 | }) 91 | -------------------------------------------------------------------------------- /src/utils/github/createComment.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | import { invariant } from 'outvariant' 3 | import { getInfo } from '../git/getInfo' 4 | 5 | export async function createComment( 6 | issueId: string, 7 | body: string, 8 | ): Promise { 9 | const repo = await getInfo() 10 | 11 | const response = await fetch( 12 | `https://api.github.com/repos/${repo.owner}/${repo.name}/issues/${issueId}/comments`, 13 | { 14 | method: 'POST', 15 | headers: { 16 | Authorization: `token ${process.env.GITHUB_TOKEN}`, 17 | 'Content-Type': 'application/json', 18 | }, 19 | body: JSON.stringify({ 20 | body, 21 | }), 22 | }, 23 | ) 24 | 25 | invariant( 26 | response.ok, 27 | 'Failed to create GitHub comment for "%s" issue.', 28 | issueId, 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/github/createGitHubRelease.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | import { format } from 'outvariant' 3 | import { lt } from 'semver' 4 | import type { ReleaseContext } from '../createContext' 5 | import { getGitHubRelease, type GitHubRelease } from './getGitHubRelease' 6 | import { log } from '../../logger' 7 | 8 | /** 9 | * Create a new GitHub release with the given release notes. 10 | * This is only called if there's no existing GitHub release 11 | * for the next release tag. 12 | * @return {string} The URL of the newly created release. 13 | */ 14 | export async function createGitHubRelease( 15 | context: ReleaseContext, 16 | notes: string, 17 | ): Promise { 18 | const { repo } = context 19 | 20 | log.info( 21 | format( 22 | 'creating a new GitHub release at "%s/%s"...', 23 | repo.owner, 24 | repo.name, 25 | ), 26 | ) 27 | 28 | // Determine if the next release should be marked as the 29 | // latest release on GitHub. For that, fetch whichever latest 30 | // release exists on GitHub and see if its version is larger 31 | // than the version we are releasing right now. 32 | const latestGitHubRelease = await getGitHubRelease('latest').catch( 33 | (error) => { 34 | log.error(`Failed to fetch the latest GitHub release:`, error) 35 | // We aren't interested in the GET endpoint errors in this context. 36 | return undefined 37 | }, 38 | ) 39 | const shouldMarkAsLatest = latestGitHubRelease 40 | ? lt(latestGitHubRelease.tag_name || '0.0.0', context.nextRelease.tag) 41 | : // Undefined is fine, it means GitHub will use its default 42 | // value for the "make_latest" property in the API. 43 | undefined 44 | 45 | /** 46 | * @see https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#create-a-release 47 | */ 48 | const response = await fetch( 49 | `https://api.github.com/repos/${repo.owner}/${repo.name}/releases`, 50 | { 51 | method: 'POST', 52 | headers: { 53 | Accept: 'application/json', 54 | Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, 55 | 'Content-Type': 'application/json', 56 | }, 57 | body: JSON.stringify({ 58 | tag_name: context.nextRelease.tag, 59 | name: context.nextRelease.tag, 60 | body: notes, 61 | make_latest: shouldMarkAsLatest?.toString(), 62 | }), 63 | }, 64 | ) 65 | 66 | if (response.status === 401) { 67 | throw new Error( 68 | 'Failed to create a new GitHub release: provided GITHUB_TOKEN does not have sufficient permissions to perform this operation. Please check your token and update it if necessary.', 69 | ) 70 | } 71 | 72 | if (response.status !== 201) { 73 | throw new Error( 74 | format( 75 | 'Failed to create a new GitHub release: GitHub API responded with status code %d.', 76 | response.status, 77 | await response.text(), 78 | ), 79 | ) 80 | } 81 | 82 | return response.json() 83 | } 84 | -------------------------------------------------------------------------------- /src/utils/github/getCommitAuthors.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | import { format } from 'outvariant' 3 | import { getInfo } from '../../utils/git/getInfo' 4 | import { ParsedCommitWithHash } from '../git/parseCommits' 5 | import { log } from '../../logger' 6 | 7 | export interface GetCommitAuthorsQuery { 8 | repository: { 9 | pullRequest: { 10 | url: string 11 | author: { login: string } 12 | commits: { 13 | nodes: Array<{ 14 | commit: { 15 | authors: { 16 | nodes: Array<{ 17 | user: { login: string } 18 | }> 19 | } 20 | } 21 | }> 22 | } 23 | } 24 | } 25 | } 26 | 27 | /** 28 | * Get a list of GitHub usernames who have contributed 29 | * to the given release commit. This analyzes all the commit 30 | * authors in the pull request referenced by the given commit. 31 | */ 32 | export async function getCommitAuthors( 33 | commit: ParsedCommitWithHash, 34 | ): Promise> { 35 | // Extract all GitHub issue references from this commit. 36 | const issueRefs: Set = new Set() 37 | for (const ref of commit.references) { 38 | if (ref.issue) { 39 | issueRefs.add(ref.issue) 40 | } 41 | } 42 | 43 | if (issueRefs.size === 0) { 44 | return new Set() 45 | } 46 | 47 | const repo = await getInfo() 48 | const queue: Promise[] = [] 49 | const authors: Set = new Set() 50 | 51 | function addAuthor(login?: string): void { 52 | if (!login) { 53 | return 54 | } 55 | authors.add(login) 56 | } 57 | 58 | for (const issueId of issueRefs) { 59 | const authorLoginPromise = new Promise(async (resolve, reject) => { 60 | const response = await fetch(`https://api.github.com/graphql`, { 61 | method: 'POST', 62 | headers: { 63 | Agent: 'ossjs/release', 64 | Accept: 'application/json', 65 | Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, 66 | 'Content-Type': 'application/json', 67 | }, 68 | body: JSON.stringify({ 69 | query: ` 70 | query GetCommitAuthors($owner: String!, $repo: String!, $pullRequestId: Int!) { 71 | repository(owner: $owner name: $repo) { 72 | pullRequest(number: $pullRequestId) { 73 | url 74 | author { 75 | login 76 | } 77 | commits(first: 100) { 78 | nodes { 79 | commit { 80 | authors(first: 100) { 81 | nodes { 82 | user { 83 | login 84 | } 85 | } 86 | } 87 | } 88 | } 89 | } 90 | } 91 | } 92 | } 93 | `, 94 | variables: { 95 | owner: repo.owner, 96 | repo: repo.name, 97 | pullRequestId: Number(issueId), 98 | }, 99 | }), 100 | }) 101 | 102 | if (!response.ok) { 103 | return reject( 104 | new Error(format('GitHub API responded with %d.', response.status)), 105 | ) 106 | } 107 | 108 | const json = await response.json() 109 | const data = json.data as GetCommitAuthorsQuery 110 | 111 | if (json.errors) { 112 | return reject( 113 | new Error( 114 | format( 115 | 'GitHub API responded with %d error(s): %j', 116 | json.errors.length, 117 | json.errors, 118 | ), 119 | ), 120 | ) 121 | } 122 | 123 | // Add pull request author. 124 | addAuthor(data.repository.pullRequest.author.login) 125 | 126 | // Add each commit author in the pull request. 127 | for (const commit of data.repository.pullRequest.commits.nodes) { 128 | for (const author of commit.commit.authors.nodes) { 129 | /** 130 | * @note In some situations, GitHub will return "user: null" 131 | * for the commit user. Nobody to add to the authors then. 132 | */ 133 | if (author.user != null) { 134 | addAuthor(author.user.login) 135 | } 136 | } 137 | } 138 | 139 | resolve() 140 | }) 141 | 142 | queue.push( 143 | authorLoginPromise.catch((error: Error) => { 144 | log.error( 145 | format( 146 | 'Failed to extract the authors for the issue #%d:', 147 | issueId, 148 | error.message, 149 | ), 150 | ) 151 | }), 152 | ) 153 | } 154 | 155 | // Extract author GitHub handles in parallel. 156 | await Promise.allSettled(queue) 157 | 158 | return authors 159 | } 160 | -------------------------------------------------------------------------------- /src/utils/github/getGitHubRelease.ts: -------------------------------------------------------------------------------- 1 | import { invariant } from 'outvariant' 2 | import fetch from 'node-fetch' 3 | import { getInfo } from '../git/getInfo' 4 | 5 | export interface GitHubRelease { 6 | tag_name: string 7 | html_url: string 8 | } 9 | 10 | export async function getGitHubRelease( 11 | tag: string | ('latest' & {}), 12 | ): Promise { 13 | const repo = await getInfo() 14 | 15 | const response = await fetch( 16 | tag === 'latest' 17 | ? `https://api.github.com/repos/${repo.owner}/${repo.name}/releases/latest` 18 | : `https://api.github.com/repos/${repo.owner}/${repo.name}/releases/tags/${tag}`, 19 | { 20 | headers: { 21 | Accept: 'application/json', 22 | Authorization: `token ${process.env.GITHUB_TOKEN}`, 23 | }, 24 | }, 25 | ) 26 | 27 | if (response.status === 404) { 28 | return undefined 29 | } 30 | 31 | invariant( 32 | response.ok, 33 | 'Failed to fetch GitHub release for tag "%s": server responded with %d.\n\n%s', 34 | tag, 35 | response.status, 36 | ) 37 | 38 | return response.json() 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/github/validateAccessToken.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | import { invariant } from 'outvariant' 3 | 4 | export const requiredGitHubTokenScopes: string[] = [ 5 | 'repo', 6 | 'admin:repo_hook', 7 | 'admin:org_hook', 8 | ] 9 | 10 | export const GITHUB_NEW_TOKEN_URL = `https://github.com/settings/tokens/new?scopes=${requiredGitHubTokenScopes.join( 11 | ',', 12 | )}` 13 | 14 | /** 15 | * Check whether the given GitHub access token has sufficient permissions 16 | * for this library to create and publish a new release. 17 | */ 18 | export async function validateAccessToken(accessToken: string): Promise { 19 | const response = await fetch('https://api.github.com', { 20 | headers: { 21 | Authorization: `Bearer ${accessToken}`, 22 | }, 23 | }) 24 | const permissions = 25 | response.headers 26 | .get('x-oauth-scopes') 27 | ?.split(',') 28 | .map((scope) => scope.trim()) || [] 29 | 30 | // Handle generic error responses. 31 | invariant( 32 | response.ok, 33 | 'Failed to verify GitHub token permissions: GitHub API responded with %d %s. Please double-check your "GITHUB_TOKEN" environmental variable and try again.', 34 | response.status, 35 | response.statusText, 36 | ) 37 | 38 | invariant( 39 | permissions.length > 0, 40 | 'Failed to verify GitHub token permissions: GitHub API responded with an empty "X-OAuth-Scopes" header.', 41 | ) 42 | 43 | const missingScopes = requiredGitHubTokenScopes.filter((scope) => { 44 | return !permissions.includes(scope) 45 | }) 46 | 47 | if (missingScopes.length > 0) { 48 | invariant( 49 | false, 50 | 'Provided "GITHUB_TOKEN" environment variable has insufficient permissions: missing scopes "%s". Please generate a new GitHub personal access token from this URL: %s', 51 | missingScopes.join(`", "`), 52 | GITHUB_NEW_TOKEN_URL, 53 | ) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/utils/readPackageJson.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | import { execAsync } from './execAsync' 4 | 5 | export function readPackageJson(): Record { 6 | const packageJsonPath = path.resolve( 7 | execAsync.contextOptions.cwd!.toString(), 8 | 'package.json', 9 | ) 10 | 11 | return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/release-notes/__test__/getReleaseNotes.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | groupCommitsByReleaseType, 3 | injectReleaseContributors, 4 | } from '../getReleaseNotes' 5 | import { mockCommit } from '../../../../test/fixtures' 6 | import { parseCommits } from '../../git/parseCommits' 7 | import { testEnvironment } from '../../../../test/env' 8 | import { graphql } from 'msw' 9 | import { GetCommitAuthorsQuery } from '../../github/getCommitAuthors' 10 | 11 | /** 12 | * groupCommitsByReleaseType. 13 | */ 14 | describe(groupCommitsByReleaseType, () => { 15 | it('groups commits by commit type', async () => { 16 | const commits = await parseCommits([ 17 | mockCommit({ 18 | subject: 'feat: support graphql', 19 | }), 20 | mockCommit({ 21 | subject: 'fix(ui): remove unsupported styles', 22 | }), 23 | mockCommit({ 24 | subject: 'chore: update dependencies', 25 | }), 26 | ]) 27 | const groups = await groupCommitsByReleaseType(commits) 28 | 29 | expect(Array.from(groups.keys())).toEqual(['feat', 'fix']) 30 | expect(Array.from(groups.get('feat')!)).toEqual([ 31 | expect.objectContaining({ 32 | type: 'feat', 33 | scope: null, 34 | header: 'feat: support graphql', 35 | }), 36 | ]) 37 | expect(Array.from(groups.get('fix')!)).toEqual([ 38 | expect.objectContaining({ 39 | type: 'fix', 40 | scope: 'ui', 41 | header: 'fix(ui): remove unsupported styles', 42 | }), 43 | ]) 44 | }) 45 | 46 | it('includes issues references', async () => { 47 | const commits = await parseCommits([ 48 | mockCommit({ 49 | subject: 'feat(api): improves stuff (#1)', 50 | }), 51 | ]) 52 | const groups = await groupCommitsByReleaseType(commits) 53 | 54 | expect(Array.from(groups.keys())).toEqual(['feat']) 55 | expect(Array.from(groups.get('feat')!)).toEqual([ 56 | expect.objectContaining({ 57 | type: 'feat', 58 | subject: 'improves stuff (#1)', 59 | references: [ 60 | expect.objectContaining({ 61 | issue: '1', 62 | prefix: '#', 63 | }), 64 | ], 65 | }), 66 | ]) 67 | }) 68 | }) 69 | 70 | describe(injectReleaseContributors, () => { 71 | const { setup, reset, cleanup, api, createRepository } = testEnvironment({ 72 | fileSystemPath: 'injectReleaseContributors', 73 | }) 74 | 75 | beforeAll(async () => { 76 | await setup() 77 | }) 78 | 79 | afterEach(async () => { 80 | await reset() 81 | }) 82 | 83 | afterAll(async () => { 84 | await cleanup() 85 | }) 86 | 87 | it('injects contributors handles alongside related commits', async () => { 88 | await createRepository('inject-contributor-handles') 89 | 90 | const pullRequests: Record< 91 | string, 92 | GetCommitAuthorsQuery['repository']['pullRequest'] 93 | > = { 94 | 1: { 95 | url: '#1', 96 | author: { login: 'octocat' }, 97 | commits: { 98 | nodes: [ 99 | { 100 | commit: { authors: { nodes: [{ user: { login: 'octocat' } }] } }, 101 | }, 102 | ], 103 | }, 104 | }, 105 | 2: { 106 | url: '#2', 107 | author: { login: 'octocat' }, 108 | commits: { 109 | nodes: [ 110 | { 111 | commit: { authors: { nodes: [{ user: { login: 'octocat' } }] } }, 112 | }, 113 | { 114 | commit: { 115 | authors: { 116 | nodes: [ 117 | { user: { login: 'octocat' } }, 118 | { user: { login: 'john.doe' } }, 119 | ], 120 | }, 121 | }, 122 | }, 123 | ], 124 | }, 125 | }, 126 | 3: { 127 | url: '#3', 128 | author: { login: 'kate' }, 129 | commits: { 130 | nodes: [ 131 | { 132 | commit: { authors: { nodes: [{ user: { login: 'kate' } }] } }, 133 | }, 134 | ], 135 | }, 136 | }, 137 | } 138 | 139 | api.use( 140 | graphql.query( 141 | 'GetCommitAuthors', 142 | (req, res, ctx) => { 143 | return res( 144 | ctx.data({ 145 | repository: { 146 | pullRequest: pullRequests[req.variables.pullRequestId], 147 | }, 148 | }), 149 | ) 150 | }, 151 | ), 152 | ) 153 | 154 | const commits = await parseCommits([ 155 | mockCommit({ 156 | subject: 'feat: first (#1)', 157 | }), 158 | mockCommit({ 159 | subject: 'fix(ui): second (#2)', 160 | }), 161 | mockCommit({ 162 | subject: 'chore: third (#3)', 163 | }), 164 | ]) 165 | const groups = await groupCommitsByReleaseType(commits) 166 | const notes = await injectReleaseContributors(groups) 167 | 168 | const features = Array.from(notes.get('feat')!) 169 | expect(features).toHaveLength(1) 170 | expect(features[0].authors).toEqual(new Set(['octocat'])) 171 | 172 | const fixes = Array.from(notes.get('fix')!) 173 | expect(fixes).toHaveLength(1) 174 | expect(fixes[0].authors).toEqual(new Set(['john.doe', 'octocat'])) 175 | }) 176 | }) 177 | -------------------------------------------------------------------------------- /src/utils/release-notes/__test__/getReleaseRefs.test.ts: -------------------------------------------------------------------------------- 1 | import { rest, ResponseResolver, RestContext, RestRequest } from 'msw' 2 | import { getReleaseRefs, IssueOrPullRequest } from '../getReleaseRefs' 3 | import { parseCommits } from '../../git/parseCommits' 4 | import { testEnvironment } from '../../../../test/env' 5 | import { mockCommit } from '../../../../test/fixtures' 6 | 7 | type IssueMap = Record 8 | 9 | const { setup, reset, cleanup, api, createRepository } = testEnvironment({ 10 | fileSystemPath: 'get-release-refs', 11 | }) 12 | 13 | beforeAll(async () => { 14 | await setup() 15 | }) 16 | 17 | afterEach(async () => { 18 | await reset() 19 | }) 20 | 21 | afterAll(async () => { 22 | await cleanup() 23 | }) 24 | 25 | function issueById( 26 | issues: IssueMap, 27 | ): ResponseResolver, RestContext> { 28 | return (req, res, ctx) => { 29 | const issue = issues[req.params.id] 30 | 31 | if (!issue) { 32 | return res(ctx.status(404)) 33 | } 34 | 35 | return res(ctx.json(issue)) 36 | } 37 | } 38 | 39 | it('extracts references from commit messages', async () => { 40 | await createRepository('from-commit-message') 41 | 42 | const issues: IssueMap = { 43 | 1: { 44 | html_url: '/issues/1', 45 | pull_request: null, 46 | body: '', 47 | }, 48 | 5: { 49 | html_url: '/issues/5', 50 | pull_request: null, 51 | body: '', 52 | }, 53 | 10: { 54 | html_url: '/issues/10', 55 | pull_request: {}, 56 | body: ` 57 | This pull request references issues in its description. 58 | 59 | - Closes #1 60 | - Fixes #5 61 | `, 62 | }, 63 | } 64 | 65 | api.use( 66 | rest.get( 67 | 'https://api.github.com/repos/:owner/:repo/issues/:id', 68 | issueById(issues), 69 | ), 70 | ) 71 | 72 | // Create a (squash) commit that references a closed pull request. 73 | const commits = await parseCommits([ 74 | mockCommit({ 75 | subject: 'fix(ui): some stuff (#10)', 76 | }), 77 | ]) 78 | const refs = await getReleaseRefs(commits) 79 | 80 | expect(refs).toEqual( 81 | new Set([ 82 | // Pull request id referenced in the squash commit message. 83 | '10', 84 | // Issue id referenced in the pull request description. 85 | '1', 86 | '5', 87 | ]), 88 | ) 89 | }) 90 | 91 | it('handles references without body', async () => { 92 | await createRepository('ref-without-body') 93 | 94 | const issues: IssueMap = { 95 | 15: { 96 | html_url: '/issues/15', 97 | pull_request: {}, 98 | // Issues or pull requests may not have any body. 99 | // That still subjects them to being included in the refs, 100 | // they just can't be parsed for any child refs. 101 | body: null, 102 | }, 103 | } 104 | 105 | api.use( 106 | rest.get( 107 | 'https://api.github.com/repos/:owner/:repo/issues/:id', 108 | issueById(issues), 109 | ), 110 | ) 111 | 112 | const commits = await parseCommits([ 113 | mockCommit({ subject: 'fix: add license' }), 114 | mockCommit({ subject: 'Make features better (#15)' }), 115 | ]) 116 | const refs = await getReleaseRefs(commits) 117 | 118 | expect(refs).toEqual(new Set(['15'])) 119 | }) 120 | -------------------------------------------------------------------------------- /src/utils/release-notes/__test__/toMarkdown.test.ts: -------------------------------------------------------------------------------- 1 | import { mockCommit, mockRepo } from '../../../../test/fixtures' 2 | import { createContext } from '../../createContext' 3 | import { getReleaseNotes } from '../getReleaseNotes' 4 | import { parseCommits } from '../../git/parseCommits' 5 | import { toMarkdown, printAuthors } from '../toMarkdown' 6 | import { api } from '../../../../test/env' 7 | import { graphql } from 'msw' 8 | import { GetCommitAuthorsQuery } from '../../github/getCommitAuthors' 9 | 10 | /** 11 | * toMarkdown. 12 | */ 13 | describe(toMarkdown, () => { 14 | const context = createContext({ 15 | repo: mockRepo(), 16 | latestRelease: undefined, 17 | nextRelease: { 18 | version: '0.1.0', 19 | publishedAt: new Date('20 Apr 2022 12:00:000 GMT'), 20 | }, 21 | }) 22 | 23 | it('includes both issue and commit reference', async () => { 24 | api.use( 25 | graphql.query( 26 | 'GetCommitAuthors', 27 | (req, res, ctx) => { 28 | req.variables.pullRequestId 29 | return res( 30 | ctx.data({ 31 | repository: { 32 | pullRequest: { 33 | url: '#1', 34 | author: { login: 'octocat' }, 35 | commits: { nodes: [] }, 36 | }, 37 | }, 38 | }), 39 | ) 40 | }, 41 | ), 42 | ) 43 | 44 | const commits = await parseCommits([ 45 | mockCommit({ 46 | hash: 'abc123', 47 | subject: 'feat(api): improves stuff (#1)', 48 | }), 49 | ]) 50 | const notes = await getReleaseNotes(commits) 51 | const markdown = toMarkdown(context, notes) 52 | 53 | expect(markdown).toContain( 54 | '- **api:** improves stuff (#1) (abc123) @octocat', 55 | ) 56 | }) 57 | 58 | it('keeps a strict order of release sections', async () => { 59 | const commits = await parseCommits([ 60 | mockCommit({ 61 | hash: 'abc123', 62 | subject: 'fix: second bugfix', 63 | }), 64 | mockCommit({ 65 | hash: 'def456', 66 | subject: 'fix: first bugfix', 67 | }), 68 | mockCommit({ 69 | hash: 'fgh789', 70 | subject: 'feat: second feature', 71 | }), 72 | mockCommit({ 73 | hash: 'xyz321', 74 | subject: 'feat: first feature', 75 | }), 76 | ]) 77 | 78 | const notes = await getReleaseNotes(commits) 79 | const markdown = toMarkdown(context, notes) 80 | 81 | expect(markdown).toEqual(`\ 82 | ## v0.1.0 (2022-04-20) 83 | 84 | ### Features 85 | 86 | - second feature (fgh789) 87 | - first feature (xyz321) 88 | 89 | ### Bug Fixes 90 | 91 | - second bugfix (abc123) 92 | - first bugfix (def456)`) 93 | }) 94 | 95 | it('lists breaking changes in a separate section', async () => { 96 | expect( 97 | toMarkdown( 98 | context, 99 | await getReleaseNotes( 100 | await parseCommits([ 101 | mockCommit({ 102 | hash: 'abc123', 103 | subject: 'fix: regular fix', 104 | }), 105 | mockCommit({ 106 | hash: 'def456', 107 | subject: 'feat: prepare functions', 108 | body: 'BREAKING CHANGE: Please use X instead of Y from now on.', 109 | }), 110 | ]), 111 | ), 112 | ), 113 | ).toEqual(`\ 114 | ## v0.1.0 (2022-04-20) 115 | 116 | ### ⚠️ BREAKING CHANGES 117 | 118 | - prepare functions (def456) 119 | 120 | Please use X instead of Y from now on. 121 | 122 | ### Bug Fixes 123 | 124 | - regular fix (abc123)`) 125 | 126 | const pullRequests: Record< 127 | string, 128 | GetCommitAuthorsQuery['repository']['pullRequest'] 129 | > = { 130 | 123: { 131 | url: '#123', 132 | author: { login: 'octocat' }, 133 | commits: { nodes: [] }, 134 | }, 135 | 456: { 136 | url: '#456', 137 | author: { login: 'john.doe' }, 138 | commits: { 139 | nodes: [ 140 | { 141 | commit: { authors: { nodes: [{ user: { login: 'kate' } }] } }, 142 | }, 143 | ], 144 | }, 145 | }, 146 | } 147 | 148 | api.use( 149 | graphql.query( 150 | 'GetCommitAuthors', 151 | (req, res, ctx) => { 152 | req.variables.pullRequestId 153 | return res( 154 | ctx.data({ 155 | repository: { 156 | pullRequest: pullRequests[req.variables.pullRequestId], 157 | }, 158 | }), 159 | ) 160 | }, 161 | ), 162 | ) 163 | 164 | expect( 165 | toMarkdown( 166 | context, 167 | await getReleaseNotes( 168 | await parseCommits([ 169 | mockCommit({ 170 | hash: 'abc123', 171 | subject: 'fix: regular fix', 172 | }), 173 | mockCommit({ 174 | hash: 'def456', 175 | subject: 'feat: prepare functions (#123)', 176 | body: 'BREAKING CHANGE: Please use X instead of Y from now on.', 177 | }), 178 | mockCommit({ 179 | hash: 'fgh789', 180 | subject: 'fix(handler): correct things (#456)', 181 | body: `\ 182 | BREAKING CHANGE: Please notice this. 183 | 184 | BREAKING CHANGE: Also notice this.`, 185 | }), 186 | ]), 187 | ), 188 | ), 189 | ).toEqual(`\ 190 | ## v0.1.0 (2022-04-20) 191 | 192 | ### ⚠️ BREAKING CHANGES 193 | 194 | - prepare functions (#123) (def456) @octocat 195 | 196 | Please use X instead of Y from now on. 197 | 198 | - **handler:** correct things (#456) (fgh789) @john.doe @kate 199 | 200 | Please notice this. 201 | 202 | Also notice this. 203 | 204 | ### Bug Fixes 205 | 206 | - regular fix (abc123)`) 207 | }) 208 | }) 209 | 210 | /** 211 | * printAuthors. 212 | */ 213 | describe(printAuthors, () => { 214 | it('returns a single github handle', () => { 215 | expect(printAuthors(new Set(['octocat']))).toBe('@octocat') 216 | }) 217 | 218 | it('returns the joined list of multiple github handles', () => { 219 | expect(printAuthors(new Set(['octocat', 'hubot']))).toBe('@octocat @hubot') 220 | }) 221 | 222 | it('returns undefinde given an empty authors set', () => { 223 | expect(printAuthors(new Set())).toBeUndefined() 224 | }) 225 | }) 226 | -------------------------------------------------------------------------------- /src/utils/release-notes/getReleaseNotes.ts: -------------------------------------------------------------------------------- 1 | import { isBreakingChange } from '../getNextReleaseType' 2 | import type { ParsedCommitWithHash } from '../git/parseCommits' 3 | import { getCommitAuthors } from '../github/getCommitAuthors' 4 | 5 | export type ReleaseNoteType = 'breaking' | 'feat' | 'fix' 6 | 7 | export type GroupedCommits = Map> 8 | 9 | export type ReleaseNoteCommit = ParsedCommitWithHash & { 10 | [key: string]: any 11 | authors: Set 12 | } 13 | 14 | export type ReleaseNotes = Map> 15 | 16 | const IGNORED_COMMIT_TYPES = ['chore'] 17 | 18 | export async function getReleaseNotes( 19 | commits: ParsedCommitWithHash[], 20 | ): Promise { 21 | const groupedNotes = await groupCommitsByReleaseType(commits) 22 | const notes = await injectReleaseContributors(groupedNotes) 23 | 24 | return notes 25 | } 26 | 27 | export async function groupCommitsByReleaseType( 28 | commits: ParsedCommitWithHash[], 29 | ): Promise { 30 | const groups: GroupedCommits = new Map() 31 | 32 | for (const commit of commits) { 33 | const { type, merge } = commit 34 | 35 | // Skip commits without a type, merge commits, and commit 36 | // types that repesent internal changes (i.e. "chore"). 37 | if (!type || merge || IGNORED_COMMIT_TYPES.includes(type)) { 38 | continue 39 | } 40 | 41 | const noteType: ReleaseNoteType = isBreakingChange(commit) 42 | ? 'breaking' 43 | : (type as ReleaseNoteType) 44 | 45 | const prevCommits = groups.get(noteType) || new Set() 46 | 47 | groups.set(noteType, prevCommits.add(commit)) 48 | } 49 | 50 | return groups 51 | } 52 | 53 | export async function injectReleaseContributors( 54 | groups: GroupedCommits, 55 | ): Promise { 56 | const notes: ReleaseNotes = new Map() 57 | 58 | for (const [releaseType, commits] of groups) { 59 | notes.set(releaseType, new Set()) 60 | 61 | for (const commit of commits) { 62 | // Don't parallelize this because then the original 63 | // order of commits may be lost. 64 | const authors = await getCommitAuthors(commit) 65 | 66 | if (authors) { 67 | const releaseCommit = Object.assign< 68 | {}, 69 | ParsedCommitWithHash, 70 | Pick 71 | >({}, commit, { 72 | authors, 73 | }) 74 | 75 | notes.get(releaseType)?.add(releaseCommit) 76 | } 77 | } 78 | } 79 | 80 | return notes 81 | } 82 | -------------------------------------------------------------------------------- /src/utils/release-notes/getReleaseRefs.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch' 2 | import createIssueParser from 'issue-parser' 3 | import { getInfo, GitInfo } from '../git/getInfo' 4 | import type { ParsedCommitWithHash } from '../git/parseCommits' 5 | 6 | const parser = createIssueParser('github') 7 | 8 | function extractIssueIds(text: string, repo: GitInfo): Set { 9 | const ids = new Set() 10 | const parsed = parser(text) 11 | 12 | for (const action of parsed.actions.close) { 13 | if (action.slug == null || action.slug === `${repo.owner}/${repo.name}`) { 14 | ids.add(action.issue) 15 | } 16 | } 17 | 18 | return ids 19 | } 20 | 21 | export async function getReleaseRefs( 22 | commits: ParsedCommitWithHash[], 23 | ): Promise> { 24 | const repo = await getInfo() 25 | const issueIds = new Set() 26 | 27 | for (const commit of commits) { 28 | // Extract issue ids from the commit messages. 29 | for (const ref of commit.references) { 30 | if (ref.issue) { 31 | issueIds.add(ref.issue) 32 | } 33 | } 34 | 35 | // Extract issue ids from the commit bodies. 36 | if (commit.body) { 37 | const bodyIssueIds = extractIssueIds(commit.body, repo) 38 | bodyIssueIds.forEach((id) => issueIds.add(id)) 39 | } 40 | } 41 | 42 | // Fetch issue detail from each issue referenced in the commit message 43 | // or commit body. Those may include pull request ids that reference 44 | // other issues. 45 | const issuesFromCommits = await Promise.all( 46 | Array.from(issueIds).map(fetchIssue), 47 | ) 48 | 49 | // Extract issue ids from the pull request descriptions. 50 | for (const issue of issuesFromCommits) { 51 | // Ignore regular issues as they may not close/fix other issues 52 | // by reference (at least on GitHub). 53 | if (!issue.pull_request || !issue.body) { 54 | continue 55 | } 56 | 57 | const descriptionIssueIds = extractIssueIds(issue.body, repo) 58 | descriptionIssueIds.forEach((id) => issueIds.add(id)) 59 | } 60 | 61 | return issueIds 62 | } 63 | 64 | export interface IssueOrPullRequest { 65 | html_url: string 66 | pull_request: Record | null 67 | body: string | null 68 | } 69 | 70 | async function fetchIssue(id: string): Promise { 71 | const repo = await getInfo() 72 | const response = await fetch( 73 | `https://api.github.com/repos/${repo.owner}/${repo.name}/issues/${id}`, 74 | { 75 | headers: { 76 | Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, 77 | }, 78 | }, 79 | ) 80 | const resource = (await response.json()) as Promise 81 | 82 | return resource 83 | } 84 | -------------------------------------------------------------------------------- /src/utils/release-notes/toMarkdown.ts: -------------------------------------------------------------------------------- 1 | import { ReleaseContext } from '../createContext' 2 | import { formatDate } from '../formatDate' 3 | import { 4 | ReleaseNoteCommit, 5 | ReleaseNotes, 6 | ReleaseNoteType, 7 | } from './getReleaseNotes' 8 | 9 | /** 10 | * Generate a Markdown string for the given release notes. 11 | */ 12 | export function toMarkdown( 13 | context: ReleaseContext, 14 | notes: ReleaseNotes, 15 | ): string { 16 | const markdown: string[] = [] 17 | const releaseDate = formatDate(context.nextRelease.publishedAt) 18 | 19 | markdown.push(`## ${context.nextRelease.tag} (${releaseDate})`) 20 | 21 | const sections: Record = { 22 | breaking: [], 23 | feat: [], 24 | fix: [], 25 | } 26 | 27 | for (const [noteType, commits] of notes) { 28 | const section = sections[noteType] 29 | 30 | if (!section) { 31 | continue 32 | } 33 | 34 | for (const commit of commits) { 35 | const releaseItem = createReleaseItem(commit, noteType === 'breaking') 36 | 37 | if (releaseItem) { 38 | section.push(...releaseItem) 39 | } 40 | } 41 | } 42 | 43 | if (sections.breaking.length > 0) { 44 | markdown.push('', '### ⚠️ BREAKING CHANGES') 45 | markdown.push(...sections.breaking) 46 | } 47 | 48 | if (sections.feat.length > 0) { 49 | markdown.push('', '### Features', '') 50 | markdown.push(...sections.feat) 51 | } 52 | 53 | if (sections.fix.length > 0) { 54 | markdown.push('', '### Bug Fixes', '') 55 | markdown.push(...sections.fix) 56 | } 57 | 58 | return markdown.join('\n') 59 | } 60 | 61 | function createReleaseItem( 62 | commit: ReleaseNoteCommit, 63 | includeCommitNotes: boolean = false, 64 | ): string[] { 65 | const { subject, scope, hash } = commit 66 | 67 | if (!subject) { 68 | return [] 69 | } 70 | 71 | const commitLine: string[] = [ 72 | [ 73 | '-', 74 | scope && `**${scope}:**`, 75 | subject, 76 | `(${hash})`, 77 | printAuthors(commit.authors), 78 | ] 79 | .filter(Boolean) 80 | .join(' '), 81 | ] 82 | 83 | if (includeCommitNotes) { 84 | const notes = commit.notes.reduce((all, note) => { 85 | return all.concat('', note.text) 86 | }, []) 87 | 88 | if (notes.length > 0) { 89 | commitLine.unshift('') 90 | commitLine.push(...notes) 91 | } 92 | } 93 | 94 | return commitLine 95 | } 96 | 97 | export function printAuthors(authors: Set): string | undefined { 98 | if (authors.size === 0) { 99 | return undefined 100 | } 101 | 102 | return Array.from(authors) 103 | .map((login) => `@${login}`) 104 | .join(' ') 105 | } 106 | -------------------------------------------------------------------------------- /src/utils/writePackageJson.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | import { execAsync } from './execAsync' 4 | 5 | export function writePackageJson(nextContent: Record): void { 6 | const packageJsonPath = path.resolve( 7 | execAsync.contextOptions.cwd!.toString(), 8 | 'package.json', 9 | ) 10 | 11 | fs.writeFileSync( 12 | packageJsonPath, 13 | /** 14 | * @fixme Do not alter the indentation. 15 | */ 16 | JSON.stringify(nextContent, null, 2), 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /test/.env.test: -------------------------------------------------------------------------------- 1 | GITHUB_TOKEN=TEST_GITHUB_TOKEN 2 | NODE_AUTH_TOKEN=TEST_NODE_AUTH_TOKEN -------------------------------------------------------------------------------- /test/env.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import { rest } from 'msw' 3 | import { SetupServer, setupServer } from 'msw/node' 4 | import { createTeardown, TeardownApi } from 'fs-teardown' 5 | import { log } from '../src/logger' 6 | import { initGit, createGitProvider } from './utils' 7 | import { execAsync } from '../src/utils/execAsync' 8 | import { requiredGitHubTokenScopes } from '../src/utils/github/validateAccessToken' 9 | 10 | export const api = setupServer( 11 | rest.get('https://api.github.com', (req, res, ctx) => { 12 | // Treat "GITHUB_TOKEN" environmental variable value during tests 13 | // as a valid GitHub personal access token with sufficient permission scopes. 14 | return res(ctx.set('x-oauth-scopes', requiredGitHubTokenScopes.join(', '))) 15 | }), 16 | ) 17 | 18 | beforeAll(() => { 19 | api.listen({ 20 | onUnhandledRequest: 'error', 21 | }) 22 | }) 23 | 24 | afterEach(() => { 25 | api.resetHandlers() 26 | }) 27 | 28 | afterAll(() => { 29 | api.close() 30 | }) 31 | 32 | export interface TestEnvironmentOptions { 33 | fileSystemPath: string 34 | } 35 | 36 | export interface TestEnvironment { 37 | setup(): Promise 38 | reset(): Promise 39 | cleanup(): Promise 40 | api: SetupServer 41 | createRepository(rootDir: string): Promise<{ 42 | fs: TeardownApi 43 | }> 44 | } 45 | 46 | export function testEnvironment( 47 | options: TestEnvironmentOptions, 48 | ): TestEnvironment { 49 | const fs = createTeardown({ 50 | // Place the test file system in node_modules to avoid 51 | // weird "/tmp" resolution issue on macOS. 52 | rootDir: path.resolve(__dirname, '..', `.tmp/${options.fileSystemPath}`), 53 | }) 54 | 55 | const subscriptions: Array<() => Promise | any> = [] 56 | const resolveSideEffects = async () => { 57 | let unsubscribe: (() => Promise | any) | undefined 58 | while ((unsubscribe = subscriptions.pop())) { 59 | await unsubscribe?.() 60 | } 61 | } 62 | 63 | return { 64 | api, 65 | async setup() { 66 | jest.spyOn(process, 'exit') 67 | jest.spyOn(process.stdout, 'write').mockImplementation() 68 | jest.spyOn(process.stderr, 'write').mockImplementation() 69 | jest.spyOn(log, 'info').mockImplementation() 70 | jest.spyOn(log, 'warn').mockImplementation() 71 | jest.spyOn(log, 'error').mockImplementation() 72 | 73 | execAsync.mockContext({ 74 | cwd: fs.resolve(), 75 | }) 76 | 77 | await fs.prepare() 78 | }, 79 | async reset() { 80 | jest.resetAllMocks() 81 | await resolveSideEffects() 82 | await fs.reset() 83 | }, 84 | async cleanup() { 85 | jest.restoreAllMocks() 86 | await resolveSideEffects() 87 | await fs.cleanup() 88 | }, 89 | async createRepository(rootDir) { 90 | const absoluteRootDir = fs.resolve(rootDir) 91 | const repoFs = createTeardown({ 92 | rootDir: absoluteRootDir, 93 | }) 94 | await repoFs.prepare() 95 | subscriptions.push(() => repoFs.cleanup()) 96 | 97 | execAsync.mockContext({ 98 | cwd: absoluteRootDir, 99 | }) 100 | subscriptions.push(() => execAsync.restoreContext()) 101 | 102 | const git = await createGitProvider( 103 | absoluteRootDir, 104 | 'octocat', 105 | path.basename(rootDir), 106 | ) 107 | subscriptions.push(() => git.client.close()) 108 | 109 | await initGit(repoFs, git.remoteUrl) 110 | 111 | return { 112 | fs: repoFs, 113 | } 114 | }, 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /test/fixtures.ts: -------------------------------------------------------------------------------- 1 | import type { Commit } from 'git-log-parser' 2 | import type { Config } from '../src/utils/getConfig' 3 | import type { GitInfo } from '../src/utils/git/getInfo' 4 | import { ParsedCommitWithHash } from '../src/utils/git/parseCommits' 5 | 6 | export function mockConfig(config: Partial = {}): Config { 7 | return { 8 | profiles: [ 9 | { 10 | name: 'latest', 11 | use: 'echo "hello world"', 12 | }, 13 | ], 14 | ...config, 15 | } 16 | } 17 | 18 | export function mockRepo(repo: Partial = {}): GitInfo { 19 | return { 20 | remote: 'git@github.com:octocat/test.git', 21 | owner: 'octocat', 22 | name: 'test', 23 | url: 'https://github.com/octocat/test/', 24 | ...repo, 25 | } 26 | } 27 | 28 | export function mockCommit(commit: Partial = {}): Commit { 29 | return { 30 | body: '', 31 | subject: '', 32 | hash: '', 33 | commit: { 34 | long: '', 35 | short: '', 36 | }, 37 | tree: { 38 | long: '', 39 | short: '', 40 | }, 41 | author: { 42 | name: 'octocat', 43 | email: 'octocat@github.com', 44 | date: new Date(), 45 | }, 46 | committer: { 47 | name: 'octocat', 48 | email: 'octocat@github.com', 49 | date: new Date(), 50 | }, 51 | ...commit, 52 | } 53 | } 54 | 55 | export function mockParsedCommit( 56 | commit: Partial = {}, 57 | ): ParsedCommitWithHash { 58 | return { 59 | subject: '', 60 | merge: '', 61 | mentions: [] as any, 62 | references: [] as any, 63 | footer: '', 64 | header: '', 65 | body: '', 66 | hash: '', 67 | notes: [] as any, 68 | revert: null as any, 69 | ...commit, 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import * as portfinder from 'portfinder' 3 | import type { TeardownApi } from 'fs-teardown' 4 | import { Git } from 'node-git-server' 5 | import { DeferredPromise } from '@open-draft/deferred-promise' 6 | 7 | /** 8 | * Initializes a new Git repository at the given path. 9 | */ 10 | export async function initGit( 11 | fs: TeardownApi, 12 | remoteUrl: string | URL, 13 | ): Promise { 14 | await fs.exec('git init') 15 | await fs.exec(`git remote add origin ${remoteUrl.toString()}`) 16 | await fs.exec( 17 | 'git config user.email "actions@github.com" && git config user.name "GitHub Actions"', 18 | ) 19 | await fs.exec('git commit -m "chore(test): initial commit" --allow-empty') 20 | 21 | /** 22 | * @note Switch to the `main` branch to support olders versions of Git. 23 | */ 24 | await fs.exec('git switch -c main').catch(() => void 0) 25 | 26 | await fs.exec('git push -u origin main') 27 | } 28 | 29 | /** 30 | * Completely removes Git from the given path. 31 | */ 32 | export async function removeGit(fs: TeardownApi): Promise { 33 | await fs.exec('rm -rf ./.git') 34 | } 35 | 36 | export async function createGitProvider( 37 | rootDir: string, 38 | owner: string, 39 | repo: string, 40 | ): Promise<{ 41 | client: Git 42 | remoteUrl: URL 43 | }> { 44 | const client = new Git(path.resolve(rootDir, '.git-provider')) 45 | client.on('push', (push) => push.accept()) 46 | client.on('fetch', (fetch) => fetch.accept()) 47 | 48 | const port = await portfinder.getPortPromise() 49 | const remoteUrl = new URL(`/${owner}/${repo}.git`, `http://localhost:${port}`) 50 | 51 | const startPromise = new DeferredPromise() 52 | client.listen(port, { type: 'http' }, () => { 53 | startPromise.resolve() 54 | }) 55 | await startPromise 56 | 57 | return { 58 | client, 59 | remoteUrl, 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "module": "CommonJS", 5 | "target": "ESNext", 6 | "sourceMap": true, 7 | "esModuleInterop": true, 8 | "moduleResolution": "node", 9 | "outDir": "./bin/build", 10 | "baseUrl": "./src" 11 | }, 12 | "include": ["./typings", "./src/**/*.ts"], 13 | "exclude": ["node_modules", "**/*.test.ts"] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "target": "esnext", 6 | "module": "esnext", 7 | "declaration": false 8 | }, 9 | "include": ["./typings", "./src/**/*.ts"], 10 | "exclude": ["node_modules"] 11 | } 12 | -------------------------------------------------------------------------------- /typings/git-log-parser.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'git-log-parser' { 2 | import { SpawnOptions } from 'child_process' 3 | 4 | export const fields: Record 5 | 6 | export function parse( 7 | config: { 8 | [option: string]: any 9 | _?: string 10 | }, 11 | options?: SpawnOptions 12 | ): NodeJS.ReadableStream 13 | 14 | export interface Commit { 15 | subject: string 16 | hash: string 17 | commit: { 18 | long: string 19 | short: string 20 | } 21 | tree: { 22 | long: string 23 | short: string 24 | } 25 | author: { 26 | name: string 27 | email: string 28 | date: Date 29 | } 30 | committer: { 31 | name: string 32 | email: string 33 | date: Date 34 | } 35 | body: string 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /typings/process.env.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | GITHUB_TOKEN?: string 5 | /** Used by NPM */ 6 | NODE_AUTH_TOKEN?: string 7 | /** Used by Yarn */ 8 | NPM_AUTH_TOKEN?: string 9 | } 10 | } 11 | } 12 | export {} 13 | --------------------------------------------------------------------------------