Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,30 @@ Publishes a new version of the package.
release publish
```

### `notes`

Generates release notes and creates a new GitHub release for the given release tag.

This command is designed to recover from a partially failed release process, as well as to generate changelogs for old releases.

- This command requires an existing (merged) release tag;
- This command accepts past release tags;
- This command has no effect if a GitHub release for the given tag already exists.

#### Arguments

| Argument name | Type | Description |
| ------------- | -------- | ------------------------ |
| `tag` | `string` | Tag name of the release. |

#### Example

```sh
# Generate release notes and create a GitHub release
# for the release tag "v1.0.3".
release notes v1.0.3
```

### `show`

Displays information about a particular release.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"bin"
],
"scripts": {
"start": "npm run build build -w",
"start": "npm run build -- -w",
"build": "tsc",
"test": "jest --runInBand",
"prerelease": "npm run build && npm test",
Expand Down
168 changes: 168 additions & 0 deletions src/commands/__test__/notes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { MockedRequest, ResponseResolver, rest, RestContext } from 'msw'
import { Notes } from '../notes'
import { commit } from '../../utils/git/commit'
import { testEnvironment } from '../../../test/env'
import { execAsync } from '../../utils/execAsync'
import { GitHubRelease } from '../../utils/github/getGitHubRelease'

const { setup, reset, cleanup, api, log } = testEnvironment('notes')

let gitHubReleaseHandler: jest.Mock = jest.fn<
ReturnType<ResponseResolver>,
Parameters<ResponseResolver<MockedRequest, RestContext>>
>((req, res, ctx) => {
return res(
ctx.status(201),
ctx.json({
html_url: '/releases/1',
}),
)
})

beforeAll(async () => {
await setup()
})

beforeEach(() => {
api.use(
rest.post(
'https://api.github.com/repos/:owner/:repo/releases',
gitHubReleaseHandler,
),
)
})

afterEach(async () => {
await reset()
})

afterAll(async () => {
await cleanup()
})

it('creates a GitHub release for a past release', async () => {
api.use(
rest.get<never, never, GitHubRelease>(
'https://api.github.com/repos/:owner/:repo/releases/tags/:tag',
(req, res, ctx) => {
return res(ctx.status(404))
},
),
)

// Preceding (previous) release.
await commit({
message: `feat: long-ago published`,
allowEmpty: true,
})
const prevReleaseCommit = await commit({
message: `chore(release): v0.1.0`,
allowEmpty: true,
})
await execAsync('git tag v0.1.0')

// Relevant release.
const fixCommit = await commit({
message: `fix: relevant fix`,
allowEmpty: true,
})
await commit({
message: `docs: not worthy of release notes`,
allowEmpty: true,
})
const featCommit = await commit({
message: `feat: relevant feature`,
allowEmpty: true,
})
const releaseCommit = await commit({
message: `chore(release): v0.2.0`,
allowEmpty: true,
date: new Date('2005-04-07T22:13:13'),
})
await execAsync(`git tag v0.2.0`)

// Future release.
await commit({
message: `fix: other that`,
allowEmpty: true,
})
await commit({
message: `chore(release): v0.2.1`,
allowEmpty: true,
})
await execAsync(`git tag v0.2.1`)

const notes = new Notes(
{
script: 'exit 0',
},
{
_: ['', '0.2.0'],
},
)
await notes.run()

expect(log.info).toHaveBeenCalledWith(
'creating GitHub release for version "v0.2.0" in "octocat/test"...',
)

expect(log.info).toHaveBeenCalledWith(
`found release tag "v0.2.0" (${releaseCommit.hash})`,
)
expect(log.info).toHaveBeenCalledWith(
`found preceding release "v0.1.0" (${prevReleaseCommit.hash})`,
)

// Must generate correct release notes.
expect(log.info).toHaveBeenCalledWith(`generated release notes:
## v0.2.0 (07/04/2005)

### Features

- relevant feature (${featCommit.hash})

### Bug Fixes

- relevant fix (${fixCommit.hash})`)

// Must create a new GitHub release.
expect(gitHubReleaseHandler).toHaveBeenCalledTimes(1)
expect(log.info).toHaveBeenCalledWith('created GitHub release: /releases/1')
})

it('skips creating a GitHub release if the given release already exists', async () => {
api.use(
rest.get<never, never, GitHubRelease>(
'https://api.github.com/repos/:owner/:repo/releases/tags/:tag',
(req, res, ctx) => {
return res(
ctx.json({
html_url: '/releases/1',
}),
)
},
),
)

const notes = new Notes(
{
script: 'exit 0',
},
{
_: ['', '1.0.0'],
},
)
await notes.run()

expect(log.warn).toHaveBeenCalledWith(
'found existing GitHub release for "v1.0.0": /releases/1',
)
expect(log.info).not.toHaveBeenCalledWith(
'creating GitHub release for version "v1.0.0" in "octocat/test"',
)
expect(log.info).not.toHaveBeenCalledWith(
expect.stringContaining('created GitHub release:'),
)

expect(process.exit).toHaveBeenCalledWith(1)
})
10 changes: 5 additions & 5 deletions src/commands/__test__/publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as fileSystem from 'fs'
import { ResponseResolver, rest } from 'msw'
import { log } from '../../logger'
import { Publish } from '../publish'
import type { CreateReleaseResponse } from '../../utils/git/createRelease'
import type { GitHubRelease } from '../../utils/github/getGitHubRelease'
import { testEnvironment } from '../../../test/env'
import { execAsync } from '../../utils/execAsync'

Expand All @@ -22,7 +22,7 @@ afterAll(async () => {

it('publishes the next minor version', async () => {
api.use(
rest.post<never, never, CreateReleaseResponse>(
rest.post<never, never, GitHubRelease>(
'https://api.github.com/repos/:owner/:repo/releases',
(req, res, ctx) => {
return res(
Expand Down Expand Up @@ -91,7 +91,7 @@ module.exports = {

it('releases a new version after an existing version', async () => {
api.use(
rest.post<never, never, CreateReleaseResponse>(
rest.post<never, never, GitHubRelease>(
'https://api.github.com/repos/:owner/:repo/releases',
(req, res, ctx) => {
return res(
Expand Down Expand Up @@ -163,7 +163,7 @@ it('comments on relevant github issues', async () => {
const commentsCreated = new Map<string, string>()

api.use(
rest.post<never, never, CreateReleaseResponse>(
rest.post<never, never, GitHubRelease>(
'https://api.github.com/repos/:owner/:repo/releases',
(req, res, ctx) => {
return res(
Expand Down Expand Up @@ -226,7 +226,7 @@ it('supports dry-run mode', async () => {
return res(ctx.status(500))
})
api.use(
rest.post<never, never, CreateReleaseResponse>(
rest.post<never, never, GitHubRelease>(
'https://api.github.com/repos/:owner/:repo/releases',
gitHubReleaseHandler,
),
Expand Down
33 changes: 20 additions & 13 deletions src/commands/__test__/show.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { execAsync } from '../../utils/execAsync'
import { getTag } from '../../utils/git/getTag'
import { testEnvironment } from '../../../test/env'
import { mockConfig } from '../../../test/fixtures'
import { commit } from '../../utils/git/commit'

const { setup, reset, cleanup, api, log } = testEnvironment('show')

Expand All @@ -20,7 +21,7 @@ afterAll(async () => {
})

it('exits given repository without any releases', async () => {
const show = new Show(mockConfig(), { _: [] })
const show = new Show(mockConfig(), { _: [''] })

await expect(show.run()).rejects.toThrow(
'Failed to retrieve release tag: repository has no releases.',
Expand Down Expand Up @@ -139,16 +140,18 @@ it('displays info for implicit unpublished release', async () => {
),
)

await execAsync('git commit -m "chore: release v1.2.3" --allow-empty')
const releaseCommit = await commit({
message: 'chore(release): v1.2.3',
allowEmpty: true,
})
await execAsync(`git tag v1.2.3`)
const pointer = await getTag('v1.2.3')

const show = new Show(mockConfig(), { _: [] })
const show = new Show(mockConfig(), { _: [''] })
await show.run()

expect(log.info).toHaveBeenCalledWith('found tag "v1.2.3"!')
expect(log.info).toHaveBeenCalledWith(
expect.stringContaining(`commit ${pointer!.hash}`),
expect.stringContaining(`commit ${releaseCommit.hash}`),
)
expect(log.info).toHaveBeenCalledWith(
`release status: ${ReleaseStatus.Unpublished}`,
Expand All @@ -173,16 +176,18 @@ it('displays info for explicit draft release', async () => {
),
)

await execAsync('git commit -m "chore: release v1.2.3" --allow-empty')
const releaseCommit = await commit({
message: 'chore(release): v1.2.3',
allowEmpty: true,
})
await execAsync(`git tag v1.2.3`)
const pointer = await getTag('v1.2.3')

const show = new Show(mockConfig(), { _: [] })
const show = new Show(mockConfig(), { _: [''] })
await show.run()

expect(log.info).toHaveBeenCalledWith('found tag "v1.2.3"!')
expect(log.info).toHaveBeenCalledWith(
expect.stringContaining(`commit ${pointer!.hash}`),
expect.stringContaining(`commit ${releaseCommit.hash}`),
)
expect(log.info).toHaveBeenCalledWith(
`release status: ${ReleaseStatus.Draft}`,
Expand All @@ -204,16 +209,18 @@ it('displays info for explicit public release', async () => {
),
)

await execAsync('git commit -m "chore: release v1.2.3" --allow-empty')
const releaseCommit = await commit({
message: 'chore(release): v1.2.3',
allowEmpty: true,
})
await execAsync(`git tag v1.2.3`)
const pointer = await getTag('v1.2.3')

const show = new Show(mockConfig(), { _: [] })
const show = new Show(mockConfig(), { _: [''] })
await show.run()

expect(log.info).toHaveBeenCalledWith('found tag "v1.2.3"!')
expect(log.info).toHaveBeenCalledWith(
expect.stringContaining(`commit ${pointer!.hash}`),
expect.stringContaining(`commit ${releaseCommit.hash}`),
)
expect(log.info).toHaveBeenCalledWith(
`release status: ${ReleaseStatus.Public}`,
Expand Down
Loading