Skip to content

Commit ce51dce

Browse files
committed
feat(core,github-action,github): support custom changelog preamble
Inject custom markdown into the changelog after the version header via a new bump.preamble option (config or JS API), or a !simple-release/set-preamble pull request comment — globally or per package through byProject.
1 parent e6c631a commit ce51dce

14 files changed

Lines changed: 447 additions & 34 deletions

File tree

packages/core/src/change-log.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export function preamblePartial(
3232
) {
3333
return segments(
3434
preamble,
35-
!commitGroups?.length && !noteGroups?.length && 'Version bump without any changes.'
35+
!preamble && !commitGroups?.length && !noteGroups?.length && 'Version bump without any changes.'
3636
)
3737
}
3838

packages/core/src/project/monorepo.types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export interface MonorepoProjectOptions extends ProjectOptions {
4545
gitClient?: ConventionalGitClient
4646
}
4747

48-
export type MonorepoProjectBumpByProjectOptions = Pick<ProjectBumpOptions, 'version' | 'as' | 'prerelease' | 'snapshot' | 'skipChangelog' | 'firstRelease' | 'skip'>
48+
export type MonorepoProjectBumpByProjectOptions = Pick<ProjectBumpOptions, 'version' | 'as' | 'prerelease' | 'snapshot' | 'skipChangelog' | 'firstRelease' | 'skip' | 'preamble'>
4949

5050
export interface MonorepoProjectBumpOptions extends Omit<ProjectBumpOptions, 'tagPrefix'> {
5151
/**

packages/core/src/project/packageJson.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,43 @@ describe('core', () => {
384384
expect(project.versionUpdates[0].notes).not.toContain('Version bump without any changes.')
385385
})
386386

387+
it('should insert the preamble after the version header', async () => {
388+
const { cwd } = await packageJsonProject()
389+
const project = new PackageJsonProject({
390+
path: join(cwd, 'package.json')
391+
})
392+
const result = await project.bump({
393+
dryRun: true,
394+
preamble: '## What\'s new?\n\n- Redesigned website'
395+
})
396+
const { notes } = project.versionUpdates[0]
397+
398+
expect(result).toBe(true)
399+
expect(notes).toContain('## What\'s new?')
400+
expect(notes).toContain('- Redesigned website')
401+
// The preamble sits between the version header and the generated sections.
402+
expect(notes.indexOf('## [2')).toBeLessThan(notes.indexOf('## What\'s new?'))
403+
expect(notes.indexOf('## What\'s new?')).toBeLessThan(notes.indexOf('### '))
404+
})
405+
406+
it('should replace the no-change placeholder with the preamble', async () => {
407+
const { cwd } = await packageJsonProject({}, {
408+
postReleaseCommits: false
409+
})
410+
const project = new PackageJsonProject({
411+
path: join(cwd, 'package.json')
412+
})
413+
const result = await project.bump({
414+
dryRun: true,
415+
as: 'patch',
416+
preamble: '## Heads up'
417+
})
418+
419+
expect(result).toBe(true)
420+
expect(project.versionUpdates[0].notes).toContain('## Heads up')
421+
expect(project.versionUpdates[0].notes).not.toContain('Version bump without any changes.')
422+
})
423+
387424
it('should get commit message after bump', async () => {
388425
const { cwd } = await packageJsonProject()
389426
const project = new PackageJsonProject({

packages/core/src/project/packageJsonMonorepo.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,35 @@ describe('core', () => {
250250
])
251251
})
252252

253+
it('should include global and per-project preambles in changelogs', async () => {
254+
const { cwd } = await forkProject('bump-preamble', packageJsonIndependentMonorepoProject())
255+
const project = new PackageJsonMonorepoProject({
256+
mode: 'independent',
257+
root: cwd,
258+
getProjects
259+
})
260+
const result = await project.bump({
261+
preamble: '## Global heads up',
262+
byProject: {
263+
'subproject-2': {
264+
preamble: '## Subproject two only'
265+
}
266+
}
267+
})
268+
269+
expect(result).toBe(true)
270+
271+
const [one, two, three] = project.versionUpdates
272+
273+
expect(one.notes).toContain('## Global heads up')
274+
expect(one.notes).not.toContain('## Subproject two only')
275+
276+
expect(two.notes).toContain('## Subproject two only')
277+
expect(two.notes).not.toContain('## Global heads up')
278+
279+
expect(three.notes).toContain('## Global heads up')
280+
})
281+
253282
it('should get commit message after bump', async () => {
254283
const { cwd } = await packageJsonIndependentMonorepoProject()
255284
const project = new PackageJsonMonorepoProject({

packages/core/src/project/project.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,7 @@ export abstract class Project {
402402
const {
403403
tagPrefix,
404404
preset = bumpDefaultOptions.preset,
405+
preamble,
405406
dryRun,
406407
skipChangelog,
407408
logger
@@ -441,7 +442,8 @@ export abstract class Project {
441442
.readRepository()
442443
.context({
443444
version: nextVersion,
444-
previousTag: lastReleaseTag
445+
previousTag: lastReleaseTag,
446+
preamble
445447
})
446448
.writer({
447449
preamblePartial

packages/core/src/project/project.types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ export interface ProjectBumpOptions {
6060
* Skip changelog generation.
6161
*/
6262
skipChangelog?: boolean
63+
/**
64+
* Custom markdown inserted into the changelog after the version header,
65+
* before the generated sections.
66+
*/
67+
preamble?: string
6368
/**
6469
* Whether this is the first release.
6570
* By default will be auto detected based on tag existence.
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import {
2+
describe,
3+
it,
4+
expect
5+
} from 'vitest'
6+
import {
7+
SET_OPTION_COMMAND,
8+
SET_PREAMBLE_COMMAND,
9+
isCommandComment,
10+
parseSetPreambleComment,
11+
parseSetOptionsComment
12+
} from './comment.js'
13+
14+
const owner = 'OWNER'
15+
16+
describe('github-action', () => {
17+
describe('comment', () => {
18+
describe('parseSetPreambleComment', () => {
19+
it('should parse a global preamble', () => {
20+
expect(parseSetPreambleComment({
21+
author_association: owner,
22+
body: `${SET_PREAMBLE_COMMAND}\n\n## What's new?\n\n- x`
23+
})).toEqual({
24+
name: undefined,
25+
preamble: '## What\'s new?\n\n- x'
26+
})
27+
})
28+
29+
it('should parse a package-scoped preamble by full name', () => {
30+
expect(parseSetPreambleComment({
31+
author_association: owner,
32+
body: `${SET_PREAMBLE_COMMAND} @org/core\n\n## Core\n\n- y`
33+
})).toEqual({
34+
name: '@org/core',
35+
preamble: '## Core\n\n- y'
36+
})
37+
})
38+
39+
it('should strip backticks around the package name', () => {
40+
expect(parseSetPreambleComment({
41+
author_association: owner,
42+
body: `${SET_PREAMBLE_COMMAND} \`@org/core\`\n\n## Core`
43+
})).toEqual({
44+
name: '@org/core',
45+
preamble: '## Core'
46+
})
47+
})
48+
49+
it('should ignore an empty preamble body', () => {
50+
expect(parseSetPreambleComment({
51+
author_association: owner,
52+
body: `${SET_PREAMBLE_COMMAND} @org/core\n\n `
53+
})).toBeNull()
54+
})
55+
56+
it('should ignore a command with a longer suffix', () => {
57+
expect(parseSetPreambleComment({
58+
author_association: owner,
59+
body: `${SET_PREAMBLE_COMMAND}-foo\n\n## Nope`
60+
})).toBeNull()
61+
})
62+
63+
it('should ignore comments from untrusted authors', () => {
64+
expect(parseSetPreambleComment({
65+
author_association: 'NONE',
66+
body: `${SET_PREAMBLE_COMMAND}\n\n## Nope`
67+
})).toBeNull()
68+
})
69+
70+
it('should ignore unrelated comments', () => {
71+
expect(parseSetPreambleComment({
72+
author_association: owner,
73+
body: 'just a normal comment'
74+
})).toBeNull()
75+
})
76+
})
77+
78+
describe('parseSetOptionsComment', () => {
79+
it('should extract the json block', () => {
80+
expect(parseSetOptionsComment({
81+
author_association: owner,
82+
body: `${SET_OPTION_COMMAND}\n\n\`\`\`json\n{ "bump": { "as": "major" } }\n\`\`\``
83+
})).toBe('{ "bump": { "as": "major" } }')
84+
})
85+
86+
it('should ignore a set-options comment without a json block', () => {
87+
expect(parseSetOptionsComment({
88+
author_association: owner,
89+
body: `${SET_OPTION_COMMAND}\n\nno code block here`
90+
})).toBeNull()
91+
})
92+
93+
it('should ignore a command with a longer suffix', () => {
94+
expect(parseSetOptionsComment({
95+
author_association: owner,
96+
body: `${SET_OPTION_COMMAND}-foo\n\n\`\`\`json\n{}\n\`\`\``
97+
})).toBeNull()
98+
})
99+
100+
it('should not treat a set-preamble comment as set-options', () => {
101+
expect(parseSetOptionsComment({
102+
author_association: owner,
103+
body: `${SET_PREAMBLE_COMMAND}\n\n## What's new?`
104+
})).toBeNull()
105+
})
106+
})
107+
108+
describe('isCommandComment', () => {
109+
it('should detect command comments', () => {
110+
expect(isCommandComment(`${SET_OPTION_COMMAND}\n\n\`\`\`json\n{}\n\`\`\``)).toBe(true)
111+
expect(isCommandComment(`${SET_PREAMBLE_COMMAND} @org/core\n\n## x`)).toBe(true)
112+
})
113+
114+
it('should ignore mentions, suffixes and empty bodies', () => {
115+
expect(isCommandComment(`please run ${SET_OPTION_COMMAND}`)).toBe(false)
116+
expect(isCommandComment(`${SET_PREAMBLE_COMMAND}-foo`)).toBe(false)
117+
expect(isCommandComment(undefined)).toBe(false)
118+
})
119+
})
120+
})
121+
})

packages/github-action/src/comment.ts

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,94 @@ const JSON_START_OFFSET = JSON_START.length
1313
const JSON_END = '```'
1414

1515
export const SET_OPTION_COMMAND = '!simple-release/set-options'
16+
export const SET_PREAMBLE_COMMAND = '!simple-release/set-preamble'
17+
18+
/**
19+
* All supported pull request comment commands.
20+
*/
21+
export const COMMANDS = [
22+
SET_OPTION_COMMAND,
23+
SET_PREAMBLE_COMMAND
24+
]
25+
26+
/**
27+
* Whether the body starts with the command followed by a word boundary, so a
28+
* longer token like `!simple-release/set-options-foo` is not treated as a match.
29+
* @param body - The comment body.
30+
* @param command - The command to look for.
31+
* @returns Whether the body is a comment for the command.
32+
*/
33+
function startsWithCommand(body: string, command: string) {
34+
if (!body.startsWith(command)) {
35+
return false
36+
}
37+
38+
const nextChar = body[command.length]
39+
40+
return nextChar === undefined || /\s/.test(nextChar)
41+
}
42+
43+
/**
44+
* Whether the comment body is one of the supported command comments.
45+
* @param body - The comment body.
46+
* @returns Whether the body starts with a supported command.
47+
*/
48+
export function isCommandComment(body: string | undefined) {
49+
return typeof body === 'string' && COMMANDS.some(command => startsWithCommand(body, command))
50+
}
1651

1752
export function parseSetOptionsComment(comment: Comment): string | null {
1853
if (ALLOWED_AUTHOR_ASSOCIATIONS.includes(comment.author_association)) {
1954
const { body } = comment
2055

21-
if (body?.startsWith(SET_OPTION_COMMAND)) {
56+
if (body && startsWithCommand(body, SET_OPTION_COMMAND)) {
2257
const start = body.indexOf(JSON_START)
2358
const end = body.lastIndexOf(JSON_END)
24-
const json = body.substring(start + JSON_START_OFFSET, end).trim()
2559

26-
return json
60+
// Require a proper ```json ... ``` block, otherwise there is nothing to parse.
61+
if (start === -1 || end <= start + JSON_START_OFFSET) {
62+
return null
63+
}
64+
65+
return body.substring(start + JSON_START_OFFSET, end).trim()
66+
}
67+
}
68+
69+
return null
70+
}
71+
72+
export interface SetPreamble {
73+
/**
74+
* The full package name the preamble targets, or undefined for the whole release.
75+
*/
76+
name?: string
77+
/**
78+
* The markdown to insert into the changelog preamble.
79+
*/
80+
preamble: string
81+
}
82+
83+
export function parseSetPreambleComment(comment: Comment): SetPreamble | null {
84+
if (ALLOWED_AUTHOR_ASSOCIATIONS.includes(comment.author_association)) {
85+
const { body } = comment
86+
87+
if (body && startsWithCommand(body, SET_PREAMBLE_COMMAND)) {
88+
const newlineIndex = body.indexOf('\n')
89+
const firstLine = newlineIndex === -1
90+
? body
91+
: body.slice(0, newlineIndex)
92+
// The argument is the full package name, optionally wrapped in backticks.
93+
const name = firstLine.slice(SET_PREAMBLE_COMMAND.length).replace(/`/g, '').trim().split(/\s+/)[0] || undefined
94+
const preamble = newlineIndex === -1
95+
? ''
96+
: body.slice(newlineIndex + 1).trim()
97+
98+
if (preamble) {
99+
return {
100+
name,
101+
preamble
102+
}
103+
}
27104
}
28105
}
29106

packages/github-action/src/conditions.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { context } from '@actions/github'
22
import type { ReleaserGithubAction } from './releaser.js'
3-
import { SET_OPTION_COMMAND } from './comment.js'
3+
import { isCommandComment } from './comment.js'
44

55
export async function ifReleaseCommit(releaser: ReleaserGithubAction) {
66
const {
@@ -24,7 +24,15 @@ export async function ifReleaseCommit(releaser: ReleaserGithubAction) {
2424
return tags.length > 0
2525
}
2626

27-
export function ifSetOptionsComment() {
27+
/**
28+
* Detect whether the current event is a supported command comment
29+
* (`!simple-release/set-options` or `!simple-release/set-preamble`) on an open
30+
* release pull request.
31+
* @returns The target branch when it is such a comment, `false` when it is a
32+
* comment event but not a command comment, or `null` when it is not a comment
33+
* event at all.
34+
*/
35+
export function ifCommandComment() {
2836
const {
2937
eventName,
3038
payload: {
@@ -43,7 +51,7 @@ export function ifSetOptionsComment() {
4351
&& issueAuthor === 'github-actions[bot]'
4452
&& issueState === 'open'
4553
&& issueBody?.includes('simple-release-pull-request: true')
46-
&& commentBody?.includes(SET_OPTION_COMMAND)
54+
&& isCommandComment(commentBody)
4755
) {
4856
const matches = issueBody.match(/simple-release-branch-to:\s*([^\s]+)/)
4957

@@ -57,3 +65,9 @@ export function ifSetOptionsComment() {
5765

5866
return null // continue
5967
}
68+
69+
/**
70+
* @deprecated Renamed to {@link ifCommandComment} — it now covers all
71+
* `!simple-release/*` command comments, not only `set-options`.
72+
*/
73+
export const ifSetOptionsComment = ifCommandComment

0 commit comments

Comments
 (0)