Skip to content

Commit 4ae412c

Browse files
authored
chore(release): Add generate-changelog script (#18999)
I know we'll be using the craft release notes soon, but this can be beneficial for the time being. I've never had a lot of luck with the cursor command and this deterministic script might help other people as well. Closes #19000 (added automatically)
1 parent aa986f5 commit 4ae412c

File tree

4 files changed

+317
-6
lines changed

4 files changed

+317
-6
lines changed

docs/publishing-a-release.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,12 @@ You can run a pre-configured command in cursor by just typing `/publish_release`
4343

4444
## Updating the Changelog
4545

46-
1. Run `yarn changelog` and copy everything.
46+
1. Run `yarn changelog` (or `yarn generate-changelog` for best-effort formatting) and copy everything.
4747
2. Create a new section in the changelog with the previously determined version number.
4848
3. Paste in the logs you copied earlier.
4949
4. If there are any important features or fixes, highlight them under the `Important Changes` subheading. If there are no important changes, don't include this section. If the `Important Changes` subheading is used, put all other user-facing changes under the `Other Changes` subheading.
5050
5. Any changes that are purely internal (e.g. internal refactors (`ref`) without user-facing changes, tests, chores, etc) should be put under a `<details>` block, where the `<summary>` heading is "Internal Changes" (see example).
51+
- Sometimes, there might be user-facing changes that are marked as `ref`, `chore` or similar - these should go in the main changelog body, not in the internal changes section.
5152
6. Make sure the changelog entries are ordered alphabetically.
5253
7. If any of the PRs are from external contributors, include underneath the commits
5354
`Work in this release contributed by <list of external contributors' GitHub usernames>. Thank you for your contributions!`.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"build:tarball": "run-s clean:tarballs build:tarballs",
1414
"build:tarballs": "lerna run build:tarball",
1515
"changelog": "ts-node ./scripts/get-commit-list.ts",
16+
"generate-changelog": "ts-node ./scripts/generate-changelog.ts",
1617
"circularDepCheck": "lerna run circularDepCheck",
1718
"clean": "run-s clean:build clean:caches",
1819
"clean:build": "lerna run clean",

scripts/generate-changelog.ts

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
import { readFileSync } from 'fs';
2+
import { join } from 'path';
3+
import { getNewGitCommits } from './get-commit-list';
4+
5+
type EntryType = 'important' | 'other' | 'internal';
6+
7+
interface ChangelogEntry {
8+
type: EntryType;
9+
content: string;
10+
sortKey: string;
11+
prNumber: string | null;
12+
}
13+
14+
// ============================================================================
15+
// Changelog Parsing
16+
// ============================================================================
17+
18+
interface ParsedChangelog {
19+
importantChanges: ChangelogEntry[];
20+
otherChanges: ChangelogEntry[];
21+
internalChanges: ChangelogEntry[];
22+
changelogPRs: Set<string>;
23+
contributorsLine: string;
24+
}
25+
26+
function getUnreleasedSection(content: string): string[] {
27+
const lines = content.split('\n');
28+
29+
const unreleasedIndex = lines.findIndex(line => line.trim() === '## Unreleased');
30+
if (unreleasedIndex === -1) {
31+
// eslint-disable-next-line no-console
32+
console.error('Could not find "## Unreleased" section in CHANGELOG.md');
33+
process.exit(1);
34+
}
35+
36+
const nextVersionIndex = lines.findIndex((line, index) => index > unreleasedIndex && /^## \d+\.\d+\.\d+/.test(line));
37+
if (nextVersionIndex === -1) {
38+
// eslint-disable-next-line no-console
39+
console.error('Could not find next version section after "## Unreleased"');
40+
process.exit(1);
41+
}
42+
43+
return lines.slice(unreleasedIndex + 1, nextVersionIndex);
44+
}
45+
46+
function createEntry(content: string, type: EntryType): ChangelogEntry {
47+
const firstLine = content.split('\n')[0] ?? content;
48+
const prNumber = extractPRNumber(firstLine);
49+
return {
50+
type,
51+
content,
52+
sortKey: extractSortKey(firstLine),
53+
prNumber,
54+
};
55+
}
56+
57+
function parseChangelog(unreleasedLines: string[]): ParsedChangelog {
58+
const importantChanges: ChangelogEntry[] = [];
59+
const otherChanges: ChangelogEntry[] = [];
60+
const internalChanges: ChangelogEntry[] = [];
61+
const changelogPRs = new Set<string>();
62+
let contributorsLine = '';
63+
64+
let currentEntry: string[] = [];
65+
let currentType: EntryType | null = null;
66+
let inDetailsBlock = false;
67+
let detailsContent: string[] = [];
68+
69+
const addEntry = (entry: ChangelogEntry): void => {
70+
if (entry.prNumber) {
71+
changelogPRs.add(entry.prNumber);
72+
}
73+
74+
if (entry.type === 'important') {
75+
importantChanges.push(entry);
76+
} else if (entry.type === 'internal') {
77+
internalChanges.push(entry);
78+
} else {
79+
otherChanges.push(entry);
80+
}
81+
};
82+
83+
const flushCurrentEntry = (): void => {
84+
if (currentEntry.length === 0 || !currentType) return;
85+
86+
// Remove trailing empty lines from the entry
87+
while (currentEntry.length > 0 && !currentEntry[currentEntry.length - 1]?.trim()) {
88+
currentEntry.pop();
89+
}
90+
91+
if (currentEntry.length === 0) return;
92+
93+
const entry = createEntry(currentEntry.join('\n'), currentType);
94+
addEntry(entry);
95+
96+
currentEntry = [];
97+
currentType = null;
98+
};
99+
100+
const processDetailsContent = (): void => {
101+
for (const line of detailsContent) {
102+
const trimmed = line.trim();
103+
if (trimmed.startsWith('-') && trimmed.includes('(#')) {
104+
const entry = createEntry(trimmed, 'internal');
105+
addEntry(entry);
106+
}
107+
}
108+
detailsContent = [];
109+
};
110+
111+
for (const line of unreleasedLines) {
112+
// Skip undefined/null lines
113+
if (line == null) continue;
114+
115+
// Skip empty lines at the start of an entry
116+
if (!line.trim() && currentEntry.length === 0) continue;
117+
118+
// Skip quote lines
119+
if (isQuoteLine(line)) continue;
120+
121+
// Capture contributors line
122+
if (isContributorsLine(line)) {
123+
contributorsLine = line;
124+
continue;
125+
}
126+
127+
// Skip section headings
128+
if (isSectionHeading(line)) {
129+
flushCurrentEntry();
130+
continue;
131+
}
132+
133+
// Handle details block
134+
if (line.includes('<details>')) {
135+
inDetailsBlock = true;
136+
detailsContent = [];
137+
continue;
138+
}
139+
140+
if (line.includes('</details>')) {
141+
inDetailsBlock = false;
142+
processDetailsContent();
143+
continue;
144+
}
145+
146+
if (inDetailsBlock) {
147+
if (!line.includes('<summary>')) {
148+
detailsContent.push(line);
149+
}
150+
continue;
151+
}
152+
153+
// Handle regular entries
154+
if (line.trim().startsWith('- ')) {
155+
flushCurrentEntry();
156+
currentEntry = [line];
157+
currentType = determineEntryType(line);
158+
} else if (currentEntry.length > 0) {
159+
currentEntry.push(line);
160+
}
161+
}
162+
163+
flushCurrentEntry();
164+
165+
return { importantChanges, otherChanges, internalChanges, changelogPRs, contributorsLine };
166+
}
167+
168+
// ============================================================================
169+
// Output Generation
170+
// ============================================================================
171+
172+
export function sortEntries(entries: ChangelogEntry[]): void {
173+
entries.sort((a, b) => a.sortKey.localeCompare(b.sortKey));
174+
}
175+
176+
function generateOutput(
177+
importantChanges: ChangelogEntry[],
178+
otherChanges: ChangelogEntry[],
179+
internalChanges: ChangelogEntry[],
180+
contributorsLine: string,
181+
): string {
182+
const output: string[] = [];
183+
184+
if (importantChanges.length > 0) {
185+
output.push('### Important Changes', '');
186+
for (const entry of importantChanges) {
187+
output.push(entry.content, '');
188+
}
189+
}
190+
191+
if (otherChanges.length > 0) {
192+
output.push('### Other Changes', '');
193+
for (const entry of otherChanges) {
194+
output.push(entry.content);
195+
}
196+
output.push('');
197+
}
198+
199+
if (internalChanges.length > 0) {
200+
output.push('<details>', ' <summary><strong>Internal Changes</strong></summary>', '');
201+
for (const entry of internalChanges) {
202+
output.push(entry.content);
203+
}
204+
output.push('', '</details>', '');
205+
}
206+
207+
if (contributorsLine) {
208+
output.push(contributorsLine);
209+
}
210+
211+
return output.join('\n');
212+
}
213+
214+
// ============================================================================
215+
// Main
216+
// ============================================================================
217+
218+
function run(): void {
219+
const changelogPath = join(__dirname, '..', 'CHANGELOG.md');
220+
const changelogContent = readFileSync(changelogPath, 'utf-8');
221+
const unreleasedLines = getUnreleasedSection(changelogContent);
222+
223+
// Parse existing changelog entries
224+
const { importantChanges, otherChanges, internalChanges, changelogPRs, contributorsLine } =
225+
parseChangelog(unreleasedLines);
226+
227+
// Add new git commits that aren't already in the changelog
228+
for (const commit of getNewGitCommits()) {
229+
const prNumber = extractPRNumber(commit);
230+
231+
// Skip duplicates
232+
if (prNumber && changelogPRs.has(prNumber)) {
233+
continue;
234+
}
235+
236+
const entry = createEntry(commit, isInternalCommit(commit) ? 'internal' : 'other');
237+
238+
if (entry.type === 'internal') {
239+
internalChanges.push(entry);
240+
} else {
241+
otherChanges.push(entry);
242+
}
243+
}
244+
245+
// Sort all categories
246+
sortEntries(importantChanges);
247+
sortEntries(otherChanges);
248+
sortEntries(internalChanges);
249+
250+
// eslint-disable-next-line no-console
251+
console.log(generateOutput(importantChanges, otherChanges, internalChanges, contributorsLine));
252+
}
253+
254+
// ============================================================================
255+
// Helper Functions
256+
// ============================================================================
257+
258+
function extractPRNumber(line: string): string | null {
259+
const match = line.match(/#(\d+)/);
260+
return match?.[1] ?? null;
261+
}
262+
263+
function extractSortKey(line: string): string {
264+
return line
265+
.trim()
266+
.replace(/^- /, '')
267+
.replace(/\*\*/g, '')
268+
.replace(/\s*\(\[#\d+\].*?\)\s*$/, '')
269+
.toLowerCase();
270+
}
271+
272+
function isQuoteLine(line: string): boolean {
273+
return line.includes('—') && (line.includes('Wayne Gretzky') || line.includes('Michael Scott'));
274+
}
275+
276+
function isContributorsLine(line: string): boolean {
277+
return line.includes('Work in this release was contributed by');
278+
}
279+
280+
function isSectionHeading(line: string): boolean {
281+
const trimmed = line.trim();
282+
return trimmed === '### Important Changes' || trimmed === '### Other Changes';
283+
}
284+
285+
function isInternalCommit(line: string): boolean {
286+
return /^- (chore|ref|test|meta)/.test(line.trim());
287+
}
288+
289+
function isImportantEntry(line: string): boolean {
290+
return line.includes('**feat') || line.includes('**fix');
291+
}
292+
293+
function determineEntryType(line: string): EntryType {
294+
if (isImportantEntry(line)) {
295+
return 'important';
296+
}
297+
if (isInternalCommit(line)) {
298+
return 'internal';
299+
}
300+
return 'other';
301+
}
302+
303+
run();

scripts/get-commit-list.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { execSync } from 'child_process';
22

3-
function run(): void {
3+
const ISSUE_URL = 'https://github.com/getsentry/sentry-javascript/pull/';
4+
5+
export function getNewGitCommits(): string[] {
46
const commits = execSync('git log --format="- %s"').toString().split('\n');
57

68
const lastReleasePos = commits.findIndex(commit => /- meta\(changelog\)/i.test(commit));
@@ -24,11 +26,15 @@ function run(): void {
2426

2527
newCommits.sort((a, b) => a.localeCompare(b));
2628

27-
const issueUrl = 'https://github.com/getsentry/sentry-javascript/pull/';
28-
const newCommitsWithLink = newCommits.map(commit => commit.replace(/#(\d+)/, `[#$1](${issueUrl}$1)`));
29+
return newCommits.map(commit => commit.replace(/#(\d+)/, `[#$1](${ISSUE_URL}$1)`));
30+
}
2931

32+
function run(): void {
3033
// eslint-disable-next-line no-console
31-
console.log(newCommitsWithLink.join('\n'));
34+
console.log(getNewGitCommits().join('\n'));
3235
}
3336

34-
run();
37+
// Only run when executed directly, not when imported
38+
if (require.main === module) {
39+
run();
40+
}

0 commit comments

Comments
 (0)