├── .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 |
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 |
--------------------------------------------------------------------------------