Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/publishing-a-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,12 @@ You can run a pre-configured command in cursor by just typing `/publish_release`

## Updating the Changelog

1. Run `yarn changelog` and copy everything.
1. Run `yarn changelog` (or `yarn generate-changelog` for best-effort formatting) and copy everything.
2. Create a new section in the changelog with the previously determined version number.
3. Paste in the logs you copied earlier.
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.
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).
- 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.
6. Make sure the changelog entries are ordered alphabetically.
7. If any of the PRs are from external contributors, include underneath the commits
`Work in this release contributed by <list of external contributors' GitHub usernames>. Thank you for your contributions!`.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"build:tarball": "run-s clean:tarballs build:tarballs",
"build:tarballs": "lerna run build:tarball",
"changelog": "ts-node ./scripts/get-commit-list.ts",
"generate-changelog": "ts-node ./scripts/generate-changelog.ts",
"circularDepCheck": "lerna run circularDepCheck",
"clean": "run-s clean:build clean:caches",
"clean:build": "lerna run clean",
Expand Down
303 changes: 303 additions & 0 deletions scripts/generate-changelog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,303 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { getNewGitCommits } from './get-commit-list';

type EntryType = 'important' | 'other' | 'internal';

interface ChangelogEntry {
type: EntryType;
content: string;
sortKey: string;
prNumber: string | null;
}

// ============================================================================
// Changelog Parsing
// ============================================================================

interface ParsedChangelog {
importantChanges: ChangelogEntry[];
otherChanges: ChangelogEntry[];
internalChanges: ChangelogEntry[];
changelogPRs: Set<string>;
contributorsLine: string;
}

function getUnreleasedSection(content: string): string[] {
const lines = content.split('\n');

const unreleasedIndex = lines.findIndex(line => line.trim() === '## Unreleased');
if (unreleasedIndex === -1) {
// eslint-disable-next-line no-console
console.error('Could not find "## Unreleased" section in CHANGELOG.md');
process.exit(1);
}

const nextVersionIndex = lines.findIndex((line, index) => index > unreleasedIndex && /^## \d+\.\d+\.\d+/.test(line));
if (nextVersionIndex === -1) {
// eslint-disable-next-line no-console
console.error('Could not find next version section after "## Unreleased"');
process.exit(1);
}

return lines.slice(unreleasedIndex + 1, nextVersionIndex);
}

function createEntry(content: string, type: EntryType): ChangelogEntry {
const firstLine = content.split('\n')[0] ?? content;
const prNumber = extractPRNumber(firstLine);
return {
type,
content,
sortKey: extractSortKey(firstLine),
prNumber,
};
}

function parseChangelog(unreleasedLines: string[]): ParsedChangelog {
const importantChanges: ChangelogEntry[] = [];
const otherChanges: ChangelogEntry[] = [];
const internalChanges: ChangelogEntry[] = [];
const changelogPRs = new Set<string>();
let contributorsLine = '';

let currentEntry: string[] = [];
let currentType: EntryType | null = null;
let inDetailsBlock = false;
let detailsContent: string[] = [];

const addEntry = (entry: ChangelogEntry): void => {
if (entry.prNumber) {
changelogPRs.add(entry.prNumber);
}

if (entry.type === 'important') {
importantChanges.push(entry);
} else if (entry.type === 'internal') {
internalChanges.push(entry);
} else {
otherChanges.push(entry);
}
};

const flushCurrentEntry = (): void => {
if (currentEntry.length === 0 || !currentType) return;

// Remove trailing empty lines from the entry
while (currentEntry.length > 0 && !currentEntry[currentEntry.length - 1]?.trim()) {
currentEntry.pop();
}

if (currentEntry.length === 0) return;

const entry = createEntry(currentEntry.join('\n'), currentType);
addEntry(entry);

currentEntry = [];
currentType = null;
};

const processDetailsContent = (): void => {
for (const line of detailsContent) {
const trimmed = line.trim();
if (trimmed.startsWith('-') && trimmed.includes('(#')) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Internal changelog entries missed due to incorrect PR format check

Medium Severity

The check trimmed.includes('(#') in processDetailsContent fails to match entries with the markdown link format. The actual changelog format is ([#18750](url)) where [ appears between ( and #, so the substring (# is never present. This causes internal entries from <details> blocks to be silently ignored - they won't appear in output and their PR numbers won't be tracked in changelogPRs, potentially leading to duplicate entries when the same commits exist in the git log.

Fix in Cursor Fix in Web

const entry = createEntry(trimmed, 'internal');
addEntry(entry);
}
}
detailsContent = [];
};

for (const line of unreleasedLines) {
// Skip undefined/null lines
if (line == null) continue;

// Skip empty lines at the start of an entry
if (!line.trim() && currentEntry.length === 0) continue;

// Skip quote lines
if (isQuoteLine(line)) continue;

// Capture contributors line
if (isContributorsLine(line)) {
contributorsLine = line;
continue;
}

// Skip section headings
if (isSectionHeading(line)) {
flushCurrentEntry();
continue;
}

// Handle details block
if (line.includes('<details>')) {
inDetailsBlock = true;
detailsContent = [];
continue;
}

if (line.includes('</details>')) {
inDetailsBlock = false;
processDetailsContent();
continue;
}

if (inDetailsBlock) {
if (!line.includes('<summary>')) {
detailsContent.push(line);
}
continue;
}

// Handle regular entries
if (line.trim().startsWith('- ')) {
flushCurrentEntry();
currentEntry = [line];
currentType = determineEntryType(line);
} else if (currentEntry.length > 0) {
currentEntry.push(line);
}
}

flushCurrentEntry();

return { importantChanges, otherChanges, internalChanges, changelogPRs, contributorsLine };
}

// ============================================================================
// Output Generation
// ============================================================================

export function sortEntries(entries: ChangelogEntry[]): void {
entries.sort((a, b) => a.sortKey.localeCompare(b.sortKey));
}

function generateOutput(
importantChanges: ChangelogEntry[],
otherChanges: ChangelogEntry[],
internalChanges: ChangelogEntry[],
contributorsLine: string,
): string {
const output: string[] = [];

if (importantChanges.length > 0) {
output.push('### Important Changes', '');
for (const entry of importantChanges) {
output.push(entry.content, '');
}
}

if (otherChanges.length > 0) {
output.push('### Other Changes', '');
for (const entry of otherChanges) {
output.push(entry.content);
}
output.push('');
}

if (internalChanges.length > 0) {
output.push('<details>', ' <summary><strong>Internal Changes</strong></summary>', '');
for (const entry of internalChanges) {
output.push(entry.content);
}
output.push('', '</details>', '');
}

if (contributorsLine) {
output.push(contributorsLine);
}

return output.join('\n');
}

// ============================================================================
// Main
// ============================================================================

function run(): void {
const changelogPath = join(__dirname, '..', 'CHANGELOG.md');
const changelogContent = readFileSync(changelogPath, 'utf-8');
const unreleasedLines = getUnreleasedSection(changelogContent);

// Parse existing changelog entries
const { importantChanges, otherChanges, internalChanges, changelogPRs, contributorsLine } =
parseChangelog(unreleasedLines);

// Add new git commits that aren't already in the changelog
for (const commit of getNewGitCommits()) {
const prNumber = extractPRNumber(commit);

// Skip duplicates
if (prNumber && changelogPRs.has(prNumber)) {
continue;
}

const entry = createEntry(commit, isInternalCommit(commit) ? 'internal' : 'other');

if (entry.type === 'internal') {
internalChanges.push(entry);
} else {
otherChanges.push(entry);
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing PR tracking causes duplicate git commit entries

Medium Severity

The duplicate detection logic checks if a PR number exists in changelogPRs but never adds newly processed PR numbers to the set. If multiple commits in the git log reference the same PR number (which can happen with cherry-picks or amended commits), all of them will be added to the output as duplicates. After adding each commit's entry, its prNumber needs to be added to changelogPRs to prevent subsequent commits with the same PR from also being included.

Fix in Cursor Fix in Web


// Sort all categories
sortEntries(importantChanges);
sortEntries(otherChanges);
sortEntries(internalChanges);

// eslint-disable-next-line no-console
console.log(generateOutput(importantChanges, otherChanges, internalChanges, contributorsLine));
}

// ============================================================================
// Helper Functions
// ============================================================================

function extractPRNumber(line: string): string | null {
const match = line.match(/#(\d+)/);
return match?.[1] ?? null;
}

function extractSortKey(line: string): string {
return line
.trim()
.replace(/^- /, '')
.replace(/\*\*/g, '')
.replace(/\s*\(\[#\d+\].*?\)\s*$/, '')
.toLowerCase();
}

function isQuoteLine(line: string): boolean {
return line.includes('—') && (line.includes('Wayne Gretzky') || line.includes('Michael Scott'));
}

function isContributorsLine(line: string): boolean {
return line.includes('Work in this release was contributed by');
}

function isSectionHeading(line: string): boolean {
const trimmed = line.trim();
return trimmed === '### Important Changes' || trimmed === '### Other Changes';
}

function isInternalCommit(line: string): boolean {
return /^- (chore|ref|test|meta)/.test(line.trim());
}

function isImportantEntry(line: string): boolean {
return line.includes('**feat') || line.includes('**fix');
}

function determineEntryType(line: string): EntryType {
if (isImportantEntry(line)) {
return 'important';
}
if (isInternalCommit(line)) {
return 'internal';
}
return 'other';
}

run();
16 changes: 11 additions & 5 deletions scripts/get-commit-list.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { execSync } from 'child_process';

function run(): void {
const ISSUE_URL = 'https://github.com/getsentry/sentry-javascript/pull/';

export function getNewGitCommits(): string[] {
const commits = execSync('git log --format="- %s"').toString().split('\n');

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

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

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

function run(): void {
// eslint-disable-next-line no-console
console.log(newCommitsWithLink.join('\n'));
console.log(getNewGitCommits().join('\n'));
}

run();
// Only run when executed directly, not when imported
if (require.main === module) {
run();
}
Loading