Skip to content

Commit 65f770a

Browse files
committed
feat: Add automatic release notes generation
- Create generate-release-notes.js script - Automatically extracts commits since last tag - Categorizes commits (features, fixes, refactors, etc.) - Generates comprehensive release notes with: - Summary with statistics - Breaking changes section - New features section - Bug fixes section - Refactoring section - Documentation updates - Full changelog link - Integrates into main CI/CD pipeline - Release notes generated automatically on each release
1 parent 1c70078 commit 65f770a

2 files changed

Lines changed: 247 additions & 6 deletions

File tree

.github/workflows/main.yml

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,17 +94,18 @@ jobs:
9494
git push origin "$TAG"
9595
echo "✅ Created and pushed tag: $TAG"
9696
97-
# Step 8: Get release notes
98-
- name: Get Release Notes
97+
# Step 8: Generate release notes automatically
98+
- name: Generate Release Notes
9999
if: steps.check_tag.outputs.exists == 'false'
100100
id: release_notes
101+
working-directory: ./scripts
101102
run: |
102103
VERSION="${{ steps.version.outputs.version }}"
103-
if [ -f "docs/RELEASE_NOTES_v${VERSION}.md" ]; then
104-
cat "docs/RELEASE_NOTES_v${VERSION}.md" > /tmp/release_notes.txt
105-
else
104+
echo "📝 Generating release notes for v${VERSION}..."
105+
node generate-release-notes.js "${VERSION}" > /tmp/release_notes.txt || {
106+
echo "⚠️ Failed to generate release notes, using default"
106107
printf "## Release v%s\n\nThis release includes bug fixes and improvements.\n\n### Changes\n- See commit history for details\n" "${VERSION}" > /tmp/release_notes.txt
107-
fi
108+
}
108109
{
109110
echo "notes<<EOF"
110111
cat /tmp/release_notes.txt

scripts/generate-release-notes.js

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Generate Release Notes Automatically
5+
*
6+
* Generates release notes from git commits since last tag/release
7+
*/
8+
9+
const { execSync } = require('child_process');
10+
const fs = require('fs');
11+
const path = require('path');
12+
13+
function exec(command) {
14+
try {
15+
return execSync(command, { encoding: 'utf8', stdio: 'pipe' }).trim();
16+
} catch (error) {
17+
return '';
18+
}
19+
}
20+
21+
function getLastTag() {
22+
const tags = exec('git tag --sort=-v:refname');
23+
if (!tags) return null;
24+
const tagList = tags.split('\n').filter(t => t.startsWith('v'));
25+
return tagList.length > 0 ? tagList[0] : null;
26+
}
27+
28+
function getCommitsSinceTag(tag) {
29+
if (!tag) {
30+
// If no tag, get all commits
31+
return exec('git log --pretty=format:"%h|%s|%b" --no-merges');
32+
}
33+
return exec(`git log ${tag}..HEAD --pretty=format:"%h|%s|%b" --no-merges`);
34+
}
35+
36+
function categorizeCommit(message) {
37+
const msg = message.toLowerCase();
38+
39+
if (msg.includes('feat') || msg.includes('add') || msg.includes('new')) {
40+
return 'feature';
41+
}
42+
if (msg.includes('fix') || msg.includes('bug') || msg.includes('error')) {
43+
return 'fix';
44+
}
45+
if (msg.includes('refactor') || msg.includes('restructure')) {
46+
return 'refactor';
47+
}
48+
if (msg.includes('docs') || msg.includes('documentation')) {
49+
return 'docs';
50+
}
51+
if (msg.includes('test') || msg.includes('coverage')) {
52+
return 'test';
53+
}
54+
if (msg.includes('chore') || msg.includes('update') || msg.includes('bump')) {
55+
return 'chore';
56+
}
57+
if (msg.includes('breaking') || msg.includes('deprecate')) {
58+
return 'breaking';
59+
}
60+
61+
return 'other';
62+
}
63+
64+
function formatCommit(commit) {
65+
const [hash, subject, ...bodyParts] = commit.split('|');
66+
const body = bodyParts.join('|').trim();
67+
68+
// Clean up subject
69+
let cleanSubject = subject.trim();
70+
71+
// Remove common prefixes
72+
cleanSubject = cleanSubject.replace(/^(feat|fix|refactor|docs|test|chore|style|perf|ci|build|revert)(\(.+?\))?:?\s*/i, '');
73+
74+
// Capitalize first letter
75+
if (cleanSubject.length > 0) {
76+
cleanSubject = cleanSubject.charAt(0).toUpperCase() + cleanSubject.slice(1);
77+
}
78+
79+
return {
80+
hash: hash.substring(0, 7),
81+
subject: cleanSubject,
82+
body: body,
83+
category: categorizeCommit(subject + ' ' + body)
84+
};
85+
}
86+
87+
function generateReleaseNotes(version) {
88+
const lastTag = getLastTag();
89+
const commits = getCommitsSinceTag(lastTag);
90+
91+
if (!commits) {
92+
return `## Release v${version}\n\nNo commits since last release.\n`;
93+
}
94+
95+
const commitList = commits.split('\n').filter(c => c.trim());
96+
const categorized = {
97+
breaking: [],
98+
feature: [],
99+
fix: [],
100+
refactor: [],
101+
docs: [],
102+
test: [],
103+
chore: [],
104+
other: []
105+
};
106+
107+
commitList.forEach(commit => {
108+
const formatted = formatCommit(commit);
109+
if (categorized[formatted.category]) {
110+
categorized[formatted.category].push(formatted);
111+
} else {
112+
categorized.other.push(formatted);
113+
}
114+
});
115+
116+
// Count statistics
117+
const stats = {
118+
total: commitList.length,
119+
features: categorized.feature.length,
120+
fixes: categorized.fix.length,
121+
refactors: categorized.refactor.length
122+
};
123+
124+
// Generate markdown
125+
let notes = `# Release Notes - v${version}\n\n`;
126+
127+
// Summary
128+
notes += `## Summary\n\n`;
129+
notes += `This release includes **${stats.total} commits** with `;
130+
const changes = [];
131+
if (stats.features > 0) changes.push(`${stats.features} new feature${stats.features > 1 ? 's' : ''}`);
132+
if (stats.fixes > 0) changes.push(`${stats.fixes} bug fix${stats.fixes > 1 ? 'es' : ''}`);
133+
if (stats.refactors > 0) changes.push(`${stats.refactors} refactor${stats.refactors > 1 ? 's' : ''}`);
134+
135+
if (changes.length > 0) {
136+
notes += changes.join(', ') + '.\n\n';
137+
} else {
138+
notes += 'various improvements.\n\n';
139+
}
140+
141+
if (lastTag) {
142+
notes += `**Previous Release:** ${lastTag}\n\n`;
143+
}
144+
145+
notes += `---\n\n`;
146+
147+
// Breaking Changes
148+
if (categorized.breaking.length > 0) {
149+
notes += `## ⚠️ Breaking Changes\n\n`;
150+
categorized.breaking.forEach(commit => {
151+
notes += `- **${commit.subject}** (${commit.hash})\n`;
152+
if (commit.body) {
153+
notes += ` ${commit.body.split('\n')[0]}\n`;
154+
}
155+
});
156+
notes += `\n`;
157+
}
158+
159+
// Features
160+
if (categorized.feature.length > 0) {
161+
notes += `## ✨ New Features\n\n`;
162+
categorized.feature.forEach(commit => {
163+
notes += `- ${commit.subject} (${commit.hash})\n`;
164+
});
165+
notes += `\n`;
166+
}
167+
168+
// Bug Fixes
169+
if (categorized.fix.length > 0) {
170+
notes += `## 🐛 Bug Fixes\n\n`;
171+
categorized.fix.forEach(commit => {
172+
notes += `- ${commit.subject} (${commit.hash})\n`;
173+
});
174+
notes += `\n`;
175+
}
176+
177+
// Refactoring
178+
if (categorized.refactor.length > 0) {
179+
notes += `## 🔧 Refactoring\n\n`;
180+
categorized.refactor.forEach(commit => {
181+
notes += `- ${commit.subject} (${commit.hash})\n`;
182+
});
183+
notes += `\n`;
184+
}
185+
186+
// Documentation
187+
if (categorized.docs.length > 0) {
188+
notes += `## 📚 Documentation\n\n`;
189+
categorized.docs.forEach(commit => {
190+
notes += `- ${commit.subject} (${commit.hash})\n`;
191+
});
192+
notes += `\n`;
193+
}
194+
195+
// Tests
196+
if (categorized.test.length > 0) {
197+
notes += `## ✅ Tests\n\n`;
198+
categorized.test.forEach(commit => {
199+
notes += `- ${commit.subject} (${commit.hash})\n`;
200+
});
201+
notes += `\n`;
202+
}
203+
204+
// Other Changes
205+
if (categorized.chore.length > 0 || categorized.other.length > 0) {
206+
notes += `## 🔨 Other Changes\n\n`;
207+
[...categorized.chore, ...categorized.other].forEach(commit => {
208+
notes += `- ${commit.subject} (${commit.hash})\n`;
209+
});
210+
notes += `\n`;
211+
}
212+
213+
// Full Changelog
214+
notes += `---\n\n`;
215+
notes += `## Full Changelog\n\n`;
216+
notes += `For the complete list of changes, see the [commit history](https://github.com/OS366/phantom/compare/${lastTag || 'HEAD~' + stats.total}...HEAD).\n\n`;
217+
218+
return notes;
219+
}
220+
221+
// Main execution
222+
const version = process.argv[2] || process.env.VERSION;
223+
if (!version) {
224+
console.error('Usage: node generate-release-notes.js <version>');
225+
console.error(' or: VERSION=<version> node generate-release-notes.js');
226+
process.exit(1);
227+
}
228+
229+
const releaseNotes = generateReleaseNotes(version);
230+
231+
// Output to file
232+
const outputPath = path.join(__dirname, '..', 'docs', `RELEASE_NOTES_v${version}.md`);
233+
fs.writeFileSync(outputPath, releaseNotes, 'utf8');
234+
235+
console.log(`✅ Release notes generated: ${outputPath}`);
236+
console.log(`\n${releaseNotes.substring(0, 500)}...\n`);
237+
238+
// Also output to stdout for GitHub Actions
239+
console.log('::set-output name=notes::' + releaseNotes.replace(/\n/g, '%0A'));
240+

0 commit comments

Comments
 (0)