Skip to content

Commit 6829286

Browse files
committed
ci: generate user-facing release notes automatically
1 parent f13c47b commit 6829286

4 files changed

Lines changed: 188 additions & 3 deletions

File tree

‎.github/scripts/publish-release.mjs‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { execFileSync } from 'node:child_process';
2-
import { appendFileSync, readFileSync, statSync, mkdirSync } from 'node:fs';
2+
import { appendFileSync, readFileSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
33
import { shouldRelease, parseVersion } from './release-version.mjs';
4+
import { previousRelease, releaseNotes } from './release-notes.mjs';
45
const gh = (...args) => execFileSync('gh', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
56
const api = (path) => JSON.parse(gh('api', path));
67
const repo = process.env.GH_REPO,
@@ -113,6 +114,14 @@ if (
113114
)
114115
throw Error('Existing release tag points to a different commit');
115116
const existing = releases().find((release) => release.tag_name === tag);
117+
const previous = previousRelease(version, releases());
118+
const historyPath = previous
119+
? `repos/${repo}/compare/${encodeURIComponent(previous.tag_name)}...${sha}?per_page=100`
120+
: `repos/${repo}/commits?sha=${sha}&per_page=100`;
121+
const pages = JSON.parse(gh('api', '--paginate', '--slurp', historyPath));
122+
const commits = previous ? pages.flatMap((page) => page.commits) : pages.flat().reverse();
123+
const notesPath = 'artifacts/release-notes.md';
124+
writeFileSync(notesPath, releaseNotes({ repo, version, sha, previous, commits, assets }));
116125
if (existing) {
117126
if (!existing.draft) throw Error('Release already exists');
118127
gh('release', 'upload', tag, ...assets, '--repo', repo, '--clobber');
@@ -128,13 +137,14 @@ if (existing) {
128137
sha,
129138
'--title',
130139
tag,
131-
'--generate-notes',
140+
'--notes-file',
141+
notesPath,
132142
'--draft',
133143
...(parseVersion(version).pre.length > 0 ? ['--prerelease'] : []),
134144
);
135145
}
136146
// Only make the release public after every validated package has been uploaded.
137-
gh('release', 'edit', tag, '--repo', repo, '--draft=false');
147+
gh('release', 'edit', tag, '--repo', repo, '--notes-file', notesPath, '--draft=false');
138148
const url = gh('release', 'view', tag, '--repo', repo, '--json', 'url', '--jq', '.url').trim();
139149
summary(
140150
`Published ${url}\n\nTested commit: ${sha}\n\nSource CI: ${run.html_url}\n\nAll six installation packages attached.`,

‎.github/scripts/release-notes.mjs‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { compareVersions, parseVersion } from './release-version.mjs';
2+
3+
export function previousRelease(version, releases) {
4+
return releases
5+
.filter((release) => {
6+
if (release.draft) return false;
7+
try {
8+
return compareVersions(release.tag_name.replace(/^v/, ''), version) < 0;
9+
} catch {
10+
return false;
11+
}
12+
})
13+
.sort((a, b) => compareVersions(b.tag_name.replace(/^v/, ''), a.tag_name.replace(/^v/, '')))[0];
14+
}
15+
16+
const escape = (text) =>
17+
Array.from(text)
18+
.filter((char) => char.charCodeAt(0) >= 32 && char.charCodeAt(0) !== 127)
19+
.join('')
20+
.replace(/[\\`*_{}[\]<>#]/g, '\\$&');
21+
22+
export function releaseNotes({ repo, version, sha, previous, commits, assets }) {
23+
parseVersion(version);
24+
const groups = { features: [], fixes: [] };
25+
const seen = new Set();
26+
for (const item of commits) {
27+
if (seen.has(item.sha)) continue;
28+
seen.add(item.sha);
29+
const [subject, ...body] = item.commit.message.split('\n');
30+
const match = /^(feat|fix|perf|ux|ui)(?:\([^)]*\))?!?:\s*(.+)$/i.exec(subject);
31+
if (!match) continue;
32+
const title = escape(match[2].trim());
33+
const detail = escape(
34+
body
35+
.filter((line) => !/^(?:Co-authored-by|Signed-off-by|Reviewed-by):/i.test(line))
36+
.join(' ')
37+
.replace(/\s+/g, ' ')
38+
.trim(),
39+
);
40+
const bullet = `- **${title}**${detail ? ` — ${detail}` : ''} ([${item.sha.slice(0, 7)}](https://github.com/${repo}/commit/${item.sha}))`;
41+
groups[match[1].toLowerCase() === 'feat' ? 'features' : 'fixes'].push(bullet);
42+
}
43+
const count = groups.features.length + groups.fixes.length;
44+
const lines = [
45+
`# PDFMathReader v${version}`,
46+
'',
47+
count
48+
? `This release includes ${groups.features.length} new features and ${groups.fixes.length} fixes and improvements${previous ? ` since ${escape(previous.tag_name)}` : ''}. See the highlights below.`
49+
: 'This release includes maintenance updates. See the full changelog for details.',
50+
'',
51+
`## ${version}`,
52+
'',
53+
];
54+
for (const [key, title] of [
55+
['features', 'New features'],
56+
['fixes', 'Fixes and improvements'],
57+
]) {
58+
if (groups[key].length) lines.push(`### ${title}`, '', ...groups[key], '');
59+
}
60+
lines.push(
61+
'## Update',
62+
'',
63+
'Quit PDFMathReader, download the package for your system below, replace the existing application, and reopen it. Your documents and settings are preserved.',
64+
'',
65+
);
66+
const labels = {
67+
'darwin-arm64': 'macOS Apple silicon',
68+
'darwin-x64': 'macOS Intel',
69+
'win32-x64': 'Windows x64',
70+
'win32-ia32': 'Windows 32-bit',
71+
'linux-x64': 'Linux x64',
72+
'linux-armv7l': 'Linux ARMv7',
73+
};
74+
for (const path of assets) {
75+
const name = path.split('/').at(-1);
76+
const target = Object.keys(labels).find((key) => name.startsWith(`PDFMathReader-${key}.`));
77+
if (!target) throw Error('Unknown release package: ' + name);
78+
lines.push(
79+
`- [${labels[target]}](https://github.com/${repo}/releases/download/v${version}/${encodeURIComponent(name)})`,
80+
);
81+
}
82+
const url = previous
83+
? `https://github.com/${repo}/compare/${encodeURIComponent(previous.tag_name)}...${sha}`
84+
: `https://github.com/${repo}/commits/${sha}`;
85+
lines.push('', `[Full changelog](${url})`, '');
86+
return lines.join('\n');
87+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { test } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { previousRelease, releaseNotes } from './release-notes.mjs';
4+
5+
test('baseline is the highest published lower semantic version, excluding drafts', () => {
6+
assert.equal(
7+
previousRelease('0.2.0', [
8+
{ tag_name: 'v0.1.9' },
9+
{ tag_name: 'v0.1.10' },
10+
{ tag_name: 'v0.2.0-beta.1', draft: true },
11+
{ tag_name: 'nightly' },
12+
{ tag_name: 'v0.2.0' },
13+
{ tag_name: 'v0.3.0' },
14+
]).tag_name,
15+
'v0.1.10',
16+
);
17+
assert.equal(previousRelease('0.1.0', []), undefined);
18+
});
19+
20+
const commit = (sha, message) => ({ sha, commit: { message } });
21+
const options = {
22+
repo: 'PDFMathTranslate/PDFMathReader',
23+
version: '0.1.3',
24+
sha: 'a'.repeat(40),
25+
previous: { tag_name: 'v0.1.2' },
26+
assets: [
27+
'artifacts/PDFMathReader-darwin-arm64.zip',
28+
'artifacts/PDFMathReader-linux-armv7l.tar.gz',
29+
],
30+
};
31+
test('notes group user changes, retain details, omit internal commits and trailers', () => {
32+
const feature = commit(
33+
'b'.repeat(40),
34+
'feat(reader): Formula OCR\n\nRecognize and copy LaTeX.\n\nCo-authored-by: Bot <bot@example.com>',
35+
);
36+
const notes = releaseNotes({
37+
...options,
38+
commits: [
39+
feature,
40+
feature,
41+
commit('c'.repeat(40), 'ux: Improve error dialog'),
42+
commit('d'.repeat(40), 'fix: Preserve preferences'),
43+
commit('e'.repeat(40), 'ci: Cache dependencies'),
44+
commit('f'.repeat(40), 'release: 0.1.3'),
45+
],
46+
});
47+
assert.match(notes, /1 new features and 2 fixes/);
48+
assert.match(notes, /\*\*Formula OCR\*\* — Recognize and copy LaTeX\./);
49+
assert.match(notes, /### Fixes and improvements/);
50+
assert.doesNotMatch(notes, /Bot|Cache dependencies|release: 0/);
51+
assert.match(notes, /releases\/download\/v0.1.3\/PDFMathReader-darwin-arm64.zip/);
52+
assert.match(notes, /Linux ARMv7/);
53+
assert.match(notes, /compare\/v0.1.2\.\.\.a{40}/);
54+
});
55+
test('first release and maintenance-only changes still have useful notes', () => {
56+
const notes = releaseNotes({ ...options, previous: undefined, commits: [] });
57+
assert.match(notes, /maintenance updates/);
58+
assert.match(notes, /commits\/a{40}/);
59+
assert.match(notes, /## Update/);
60+
});
61+
test('commit text cannot inject HTML or Markdown headings', () => {
62+
const notes = releaseNotes({
63+
...options,
64+
commits: [commit('b'.repeat(40), 'fix: <script>\n\n# heading')],
65+
});
66+
assert.doesNotMatch(notes, /<script>/);
67+
assert.match(notes, /\\# heading/);
68+
});

‎doc/ci-release.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,23 @@ fixes; bump the version again to release a fix.
5959

6060
The packages retain the existing CI signing policy: macOS builds are unsigned
6161
and are not notarized. This workflow does not introduce signing credentials.
62+
63+
## Automatic release notes
64+
65+
CI writes the release description from commits between the highest earlier
66+
published semantic-version release and the exact tested commit. Draft releases
67+
are excluded from the baseline. The first release uses the commit history up to
68+
that tested commit. All history pages are included.
69+
70+
The description contains the version, a change-count summary, **New features**
71+
(`feat:`), **Fixes and improvements** (`fix:`, `perf:`, `ux:`, `ui:`), update
72+
instructions, links to the six validated installation packages, and a full
73+
changelog link. Scoped prefixes such as `feat(reader):` are supported. Each
74+
change has a bold title and includes its commit body when provided; write
75+
user-facing titles and bodies to produce useful descriptions. Internal commits
76+
such as `ci:`, `docs:`, `chore:`, and release markers stay in the full changelog.
77+
No AI service or additional credentials are required.
78+
79+
The generated description is applied both to new drafts and when retrying an
80+
existing draft, before making the release public. Published releases are not
81+
rewritten. Regression tests run as part of the release gate.

0 commit comments

Comments
 (0)