Skip to content

Commit 2765d58

Browse files
authored
fix(release): generate accurate rounded release notes (#189)
* fix(release): render contributor avatar cards * fix(release): summarize actual release changes * fix(release): omit separate PR section
1 parent ff78eb1 commit 2765d58

3 files changed

Lines changed: 119 additions & 43 deletions

File tree

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ jobs:
191191
cli.js cli/ lib/ plugins/ web-ui.html web-ui/ \
192192
node_modules/ package.json LICENSE README.md README.zh.md
193193
echo "STANDALONE_TGZ=$name" >> "$GITHUB_ENV"
194-
- name: Print release changelog
194+
- name: Generate release notes from actual commit range
195195
env:
196196
RELEASE_TAG: ${{ steps.resolve.outputs.release_tag }}
197197
TAG_EXISTS: ${{ steps.resolve.outputs.tag_exists }}

tests/unit/release-changelog.test.mjs

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ const {
99
parseLogLine,
1010
groupCommits,
1111
listContributors,
12+
contributorProfile,
13+
formatContributorCard,
14+
formatChangeSummary,
1215
compareUrl,
1316
formatChangelog
1417
} = require('../../tools/release/changelog.js');
@@ -33,8 +36,7 @@ test('release changelog groups PR commits and direct commits for action logs', (
3336
];
3437
const grouped = groupCommits(commits);
3538

36-
assert.deepStrictEqual(grouped.prs.map((commit) => commit.pr), [180]);
37-
assert.deepStrictEqual(grouped.directCommits.map((commit) => commit.hash), ['abc1234']);
39+
assert.deepStrictEqual(grouped.directCommits.map((commit) => commit.hash), ['f5700cf', 'abc1234']);
3840
assert.deepStrictEqual(listContributors(commits), ['awsl233777']);
3941

4042
const changelog = formatChangelog({
@@ -45,15 +47,43 @@ test('release changelog groups PR commits and direct commits for action logs', (
4547
commits
4648
});
4749

48-
assert.match(changelog, /## codexmate v0\.0\.39/);
49-
assert.match(changelog, /Changes since v0\.0\.38/);
50-
assert.match(changelog, /PRs:/);
51-
assert.match(changelog, /#180 fix\(proxy\): bypass responses probe for streaming codex tasks \(f5700cf\)/);
52-
assert.match(changelog, /Commits without PR:/);
50+
assert.doesNotMatch(changelog, /## codexmate v0\.0\.39/);
51+
assert.doesNotMatch(changelog, /Changes since v0\.0\.38/);
52+
assert.match(changelog, /### Changes/);
53+
assert.match(changelog, /- proxy: bypass responses probe for streaming codex tasks \(#180\)/);
54+
assert.match(changelog, /- update generated assets/);
55+
assert.doesNotMatch(changelog, /### PRs/);
56+
assert.match(changelog, /### Commits without PR/);
57+
assert.match(changelog, /f5700cf fix\(proxy\): bypass responses probe for streaming codex tasks \(#180\)/);
5358
assert.match(changelog, /abc1234 chore: update generated assets/);
5459
assert.match(changelog, /https:\/\/github\.com\/SakuraByteCore\/codexmate\/compare\/v0\.0\.38\.\.\.v0\.0\.39/);
55-
assert.match(changelog, /### Contributors\n- awsl233777/);
56-
assert.ok(changelog.trimEnd().endsWith('- awsl233777'));
60+
assert.match(changelog, /### Contributors\n<a href="https:\/\/github\.com\/awsl233777" title="Awsl">/);
61+
assert.match(changelog, /<img src="https:\/\/wsrv\.nl\/\?url=https%3A%2F%2Fgithub\.com%2Fawsl233777\.png%3Fsize%3D96&w=96&h=96&fit=cover&mask=circle" width="64" height="64" alt="Awsl" \/>/);
62+
assert.doesNotMatch(changelog, /<sub><b>Awsl<\/b><\/sub>/);
63+
assert.ok(changelog.trimEnd().endsWith('</a>'));
64+
});
65+
66+
test('release changelog summarizes actual commit changes without release housekeeping', () => {
67+
const commits = [
68+
parseLogLine('628d451\u001ffeat: add Claude proxy target APIs with Ollama support (#171)\u001fAwsl'),
69+
parseLogLine('5b92004\u001ffeat(web-ui): add Prompts tab for inline AGENTS.md and CLAUDE.md editing\u001fymkiux'),
70+
parseLogLine('1587cce\u001fchore: bump version to 0.0.45\u001fymkiux')
71+
];
72+
73+
assert.deepStrictEqual(formatChangeSummary(commits), [
74+
'- add Claude proxy target APIs with Ollama support (#171)',
75+
'- web-ui: add Prompts tab for inline AGENTS.md and CLAUDE.md editing'
76+
]);
77+
});
78+
79+
test('release changelog maps contributor display names to GitHub avatar cards', () => {
80+
assert.deepStrictEqual(contributorProfile('Awsl'), { login: 'awsl233777', displayName: 'Awsl' });
81+
assert.deepStrictEqual(contributorProfile('ymkiux'), { login: 'ymkiux', displayName: 'ymkiux' });
82+
83+
const card = formatContributorCard('ymkiux');
84+
assert.match(card, /href="https:\/\/github\.com\/ymkiux"/);
85+
assert.match(card, /src="https:\/\/wsrv\.nl\/\?url=https%3A%2F%2Fgithub\.com%2Fymkiux\.png%3Fsize%3D96&w=96&h=96&fit=cover&mask=circle"/);
86+
assert.doesNotMatch(card, /<sub><b>ymkiux<\/b><\/sub>/);
5787
});
5888

5989
test('release changelog reports initial release when no previous tag exists', () => {
@@ -65,7 +95,7 @@ test('release changelog reports initial release when no previous tag exists', ()
6595
commits: []
6696
});
6797

68-
assert.match(changelog, /## codexmate v0\.0\.1/);
98+
assert.doesNotMatch(changelog, /## codexmate v0\.0\.1/);
6999
assert.match(changelog, /No previous semver tag was found/);
70100
assert.match(changelog, /### Contributors\n- Unknown contributor/);
71101
assert.equal(compareUrl('SakuraByteCore/codexmate', '', 'v0.0.1', 'HEAD'), '');
@@ -75,6 +105,7 @@ test('release workflow uses generated changelog as release body', () => {
75105
const workflow = fs.readFileSync('.github/workflows/release.yml', 'utf8');
76106

77107
assert.match(workflow, /RELEASE_CHANGELOG_FILE:\s*release-changelog\.md/);
108+
assert.match(workflow, /Generate release notes from actual commit range/);
78109
assert.match(workflow, /node tools\/release\/changelog\.js/);
79110
assert.match(workflow, /test -s "\$\{RELEASE_CHANGELOG_FILE\}"/);
80111
assert.match(workflow, /body_path:\s*\$\{\{ env\.RELEASE_CHANGELOG_FILE \}\}/);

tools/release/changelog.js

Lines changed: 77 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -88,27 +88,48 @@ function readCommits(previousTag, currentRef) {
8888
}
8989

9090
function groupCommits(commits) {
91-
const prs = [];
92-
const seenPrs = new Set();
93-
const directCommits = [];
94-
for (const commit of commits) {
95-
if (commit.pr) {
96-
if (!seenPrs.has(commit.pr)) {
97-
seenPrs.add(commit.pr);
98-
prs.push(commit);
99-
}
100-
} else {
101-
directCommits.push(commit);
102-
}
103-
}
104-
return { prs, directCommits };
91+
return { directCommits: commits };
10592
}
10693

10794
function formatContributorName(author) {
10895
const value = String(author || '').trim();
10996
return value || 'Unknown contributor';
11097
}
11198

99+
const CONTRIBUTOR_PROFILES = new Map([
100+
['awsl', { login: 'awsl233777', displayName: 'Awsl' }],
101+
['awsl233777', { login: 'awsl233777', displayName: 'Awsl' }]
102+
]);
103+
104+
function escapeHtml(value) {
105+
return String(value || '')
106+
.replace(/&/g, '&amp;')
107+
.replace(/</g, '&lt;')
108+
.replace(/>/g, '&gt;')
109+
.replace(/"/g, '&quot;')
110+
.replace(/'/g, '&#39;');
111+
}
112+
113+
function contributorProfile(author) {
114+
const displayName = formatContributorName(author);
115+
const mapped = CONTRIBUTOR_PROFILES.get(displayName.toLowerCase());
116+
if (mapped) return mapped;
117+
return { login: displayName, displayName };
118+
}
119+
120+
function formatContributorCard(author) {
121+
const { login, displayName } = contributorProfile(author);
122+
const safeLogin = encodeURIComponent(login);
123+
const safeDisplayName = escapeHtml(displayName);
124+
const githubAvatarUrl = `https://github.com/${safeLogin}.png?size=96`;
125+
const roundedAvatarUrl = `https://wsrv.nl/?url=${encodeURIComponent(githubAvatarUrl)}&w=96&h=96&fit=cover&mask=circle`;
126+
return [
127+
`<a href="https://github.com/${safeLogin}" title="${safeDisplayName}">`,
128+
` <img src="${roundedAvatarUrl}" width="64" height="64" alt="${safeDisplayName}" />`,
129+
`</a>`
130+
].join('\n');
131+
}
132+
112133
function listContributors(commits) {
113134
const seen = new Set();
114135
const contributors = [];
@@ -128,39 +149,60 @@ function compareUrl(repository, previousTag, currentTag, currentRef) {
128149
return `https://github.com/${repository}/compare/${previousTag}...${right}`;
129150
}
130151

152+
function stripPullRequestSuffix(subject) {
153+
return String(subject || '').replace(/\s*\(#\d+\)\s*$/, '').trim();
154+
}
155+
156+
function formatChangeSummaryLine(commit) {
157+
const subject = stripPullRequestSuffix(commit?.subject || '');
158+
if (!subject) return '';
159+
if (/^chore:\s*bump version\b/i.test(subject)) return '';
160+
161+
const conventional = subject.match(/^(\w+)(?:\(([^)]+)\))?:\s*(.+)$/);
162+
const summary = conventional
163+
? `${conventional[2] ? `${conventional[2]}: ` : ''}${conventional[3]}`
164+
: subject;
165+
return `- ${summary}${commit.pr ? ` (#${commit.pr})` : ''}`;
166+
}
167+
168+
function formatChangeSummary(commits) {
169+
const lines = [];
170+
const seen = new Set();
171+
for (const commit of commits) {
172+
const line = formatChangeSummaryLine(commit);
173+
if (!line) continue;
174+
const key = line.toLowerCase();
175+
if (seen.has(key)) continue;
176+
seen.add(key);
177+
lines.push(line);
178+
}
179+
return lines;
180+
}
181+
131182
function formatChangelog({ repository = '', previousTag = '', currentTag = '', currentRef = 'HEAD', commits = [] }) {
132-
const currentLabel = currentTag || currentRef || 'HEAD';
133183
const lines = [];
134-
const releaseName = repository ? repository.split('/').pop() : 'Release';
135-
lines.push(`## ${releaseName} ${currentLabel}`);
136-
lines.push('');
137184

138185
if (!previousTag) {
139-
lines.push(`### Changes`);
140186
lines.push('No previous semver tag was found. Treating this as the initial release.');
141187
lines.push('');
142188
lines.push('### Contributors');
143189
lines.push('- Unknown contributor');
144190
return `${lines.join('\n')}\n`;
145191
}
146192

147-
lines.push(`### Changes since ${previousTag}`);
148-
lines.push('');
149-
150-
const { prs, directCommits } = groupCommits(commits);
193+
const { directCommits } = groupCommits(commits);
151194
if (!commits.length) {
152195
lines.push('No commits found in this range.');
153196
} else {
154-
if (prs.length) {
155-
lines.push('PRs:');
156-
for (const commit of prs) {
157-
lines.push(`- #${commit.pr} ${commit.subject.replace(/\s*\(#\d+\)\s*$/, '')} (${commit.hash})`);
158-
}
197+
const changeSummary = formatChangeSummary([...commits].reverse());
198+
if (changeSummary.length) {
199+
lines.push('### Changes');
200+
lines.push(...changeSummary);
159201
lines.push('');
160202
}
161203

162204
if (directCommits.length) {
163-
lines.push('Commits without PR:');
205+
lines.push('### Commits without PR');
164206
for (const commit of directCommits) {
165207
lines.push(`- ${commit.hash} ${commit.subject}${commit.author ? ` — ${commit.author}` : ''}`);
166208
}
@@ -179,9 +221,7 @@ function formatChangelog({ repository = '', previousTag = '', currentTag = '', c
179221
if (!contributors.length) {
180222
lines.push('- Unknown contributor');
181223
} else {
182-
for (const contributor of contributors) {
183-
lines.push(`- ${contributor}`);
184-
}
224+
lines.push(contributors.map(formatContributorCard).join('\n&nbsp;&nbsp;\n'));
185225
}
186226
return `${lines.join('\n').replace(/\n{3,}/g, '\n\n')}\n`;
187227
}
@@ -233,6 +273,11 @@ module.exports = {
233273
parseLogLine,
234274
groupCommits,
235275
listContributors,
276+
contributorProfile,
277+
formatContributorCard,
278+
stripPullRequestSuffix,
279+
formatChangeSummaryLine,
280+
formatChangeSummary,
236281
compareUrl,
237282
formatChangelog,
238283
main

0 commit comments

Comments
 (0)