From 9f056256774d0706ef74d8f09e5e49d6bcac0f32 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 2 Jun 2026 19:42:05 -0400 Subject: [PATCH] chore(#625): automate GitHub release-notes formatting (#626) Release notes were hand-edited after every release to turn GitHub's flat --generate-notes output into the curated Install + Feature/Enhancement/Fix format. Add scripts/release-notes/format-github-release-notes.cjs to do this deterministically (classifying each PR by its conventional-commit title prefix) and wire it into the pre-release, final, and hotfix release steps so it runs right after `gh release create --generate-notes`. Closes #625 Co-authored-by: Claude Opus 4.8 --- .github/workflows/hotfix.yml | 4 + .github/workflows/release.yml | 8 + CONTRIBUTING.md | 21 ++ .../format-github-release-notes.cjs | 256 ++++++++++++++++++ tests/format-github-release-notes.test.cjs | 220 +++++++++++++++ 5 files changed, 509 insertions(+) create mode 100644 scripts/release-notes/format-github-release-notes.cjs create mode 100644 tests/format-github-release-notes.test.cjs diff --git a/.github/workflows/hotfix.yml b/.github/workflows/hotfix.yml index 1bed1b21e..f40090446 100644 --- a/.github/workflows/hotfix.yml +++ b/.github/workflows/hotfix.yml @@ -376,6 +376,10 @@ jobs: --generate-notes \ --latest fi + # Reformat the auto-generated notes into the curated + # Install + Feature/Enhancement/Fix format. + node scripts/release-notes/format-github-release-notes.cjs \ + --tag "v${VERSION}" --latest --apply - name: Create PR to merge hotfix back to main if: ${{ !inputs.dry_run }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e4d7c2141..d04647649 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -238,6 +238,10 @@ jobs: --title "v${PRE_VERSION}" \ --generate-notes \ --prerelease + # Reformat the auto-generated notes into the curated + # Install + Feature/Enhancement/Fix format. + node scripts/release-notes/format-github-release-notes.cjs \ + --tag "v${PRE_VERSION}" --prerelease --apply - name: Verify publish if: ${{ !inputs.dry_run }} @@ -382,6 +386,10 @@ jobs: --title "v${VERSION}" \ --generate-notes \ --latest + # Reformat the auto-generated notes into the curated + # Install + Feature/Enhancement/Fix format. + node scripts/release-notes/format-github-release-notes.cjs \ + --tag "v${VERSION}" --latest --apply - name: Clean up next dist-tag if: ${{ !inputs.dry_run }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ec3d0765b..c200da508 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -207,6 +207,27 @@ Fragments are consolidated into `CHANGELOG.md` at release time by the release wo **Opt-out:** PRs with no user-facing impact (test refactors, lint config changes, CI tweaks, formatting-only changes) can add the `no-changelog` label. The lint honors it. When unsure whether a change is user-facing, **add the fragment**. +### Release notes formatting + +GitHub release notes are generated automatically. The release and hotfix +workflows first create the release with `gh release create --generate-notes`, +then run `scripts/release-notes/format-github-release-notes.cjs --apply` to +rewrite the body into the project's curated format: an **Install** block, +followed by **What's Changed** grouped into **Feature** / **Enhancement** / +**Fix** sections (classified by each PR's conventional-commit title prefix — +`feat` → Feature, `fix` → Fix, everything else → Enhancement), then +**New Contributors** and the **Full Changelog** link. + +To re-format an existing release by hand (e.g. backfilling an older release): + +```bash +node scripts/release-notes/format-github-release-notes.cjs \ + --tag vX.Y.Z --repo open-gsd/gsd-core --apply +``` + +Omit `--apply` to print the reformatted body to stdout for review without +publishing. + ## Documentation Updates — Update the Relevant Docs If your PR adds, changes, deprecates, or removes user-visible behavior, you **must** update the relevant documentation in `docs/`. CI will fail any PR whose changeset fragment is typed `Added`, `Changed`, `Deprecated`, or `Removed` without also modifying at least one file under `docs/` ([#3213](https://github.com/open-gsd/gsd-core/issues/3213)). diff --git a/scripts/release-notes/format-github-release-notes.cjs b/scripts/release-notes/format-github-release-notes.cjs new file mode 100644 index 000000000..85c7733cf --- /dev/null +++ b/scripts/release-notes/format-github-release-notes.cjs @@ -0,0 +1,256 @@ +'use strict'; + +const path = require('path'); +const os = require('os'); +const fs = require('fs'); +const { execFileSync } = require('child_process'); + +/** + * Classify a What's-Changed bullet line into 'Feature', 'Fix', or 'Enhancement'. + * @param {string} bulletLine - Full bullet line including the leading `* ` or `- ` marker. + * @returns {'Feature'|'Fix'|'Enhancement'} + */ +function classifyTitle(bulletLine) { + // Strip leading `* ` or `- ` marker + const withoutMarker = bulletLine.replace(/^[*-]\s+/, ''); + + // Extract title = text before ` by @` + const byIdx = withoutMarker.indexOf(' by @'); + const title = (byIdx !== -1 ? withoutMarker.slice(0, byIdx) : withoutMarker).trim(); + + if (/^feat(?:ure)?\s*(?:\(|!|:)/i.test(title)) return 'Feature'; + if (/^fix\s*(?:\(|!|:)/i.test(title)) return 'Fix'; + return 'Enhancement'; +} + +/** + * Reformat GitHub's auto-generated release notes into the repo's hand-curated format. + * + * @param {object} opts + * @param {string} opts.generatedBody - The raw GitHub-generated release body. + * @param {string} opts.version - Version string (e.g. "1.3.0-rc.1"), no leading "v". + * @param {boolean} opts.prerelease - Whether this is a pre-release. + * @param {string} opts.packageName - npm package name (e.g. "@opengsd/gsd-core"). + * @returns {string} Formatted release body (no trailing newline). + */ +function formatReleaseNotes({ generatedBody, version, prerelease, packageName }) { + const lines = generatedBody.split('\n'); + + const featureBullets = []; + const fixBullets = []; + const enhancementBullets = []; + const newContributorBullets = []; + let fullChangelogLine = null; + + let inWhatsChanged = false; + let inNewContributors = false; + + for (const line of lines) { + const trimmed = line.trim(); + + // Detect section headings + if (trimmed === '## What\'s Changed') { + inWhatsChanged = true; + inNewContributors = false; + continue; + } + + if (trimmed === '## New Contributors') { + inWhatsChanged = false; + inNewContributors = true; + continue; + } + + // Full changelog line ends the What's Changed section + if (trimmed.startsWith('**Full Changelog**:')) { + inWhatsChanged = false; + inNewContributors = false; + fullChangelogLine = trimmed; + continue; + } + + // Any other `##` heading ends current section + if (trimmed.startsWith('## ')) { + inWhatsChanged = false; + inNewContributors = false; + continue; + } + + // Collect bullets + if (inWhatsChanged && (trimmed.startsWith('* ') || trimmed.startsWith('- '))) { + const category = classifyTitle(trimmed); + if (category === 'Feature') featureBullets.push(trimmed); + else if (category === 'Fix') fixBullets.push(trimmed); + else enhancementBullets.push(trimmed); + continue; + } + + if (inNewContributors && (trimmed.startsWith('* ') || trimmed.startsWith('- '))) { + newContributorBullets.push(trimmed); + continue; + } + } + + // Build Install block + let installBlock; + if (prerelease) { + installBlock = [ + '## Install', + '', + 'This pre-release is published to npm under the `next` dist-tag.', + '', + '```bash', + `npm i ${packageName}@${version}`, + '# or', + `npm i ${packageName}@next`, + '```', + ].join('\n'); + } else { + installBlock = [ + '## Install', + '', + '```bash', + `npm i ${packageName}@${version}`, + '# or', + `npm i ${packageName}@latest`, + '```', + ].join('\n'); + } + + // Assemble groups (omit empty ones) + const groups = []; + + // Group A: Install + groups.push(installBlock); + + // Group B: What's Changed heading + groups.push('## What\'s Changed'); + + // Group C: Features + if (featureBullets.length > 0) { + groups.push('### Feature\n' + featureBullets.join('\n')); + } + + // Group D: Enhancements + if (enhancementBullets.length > 0) { + groups.push('### Enhancement\n' + enhancementBullets.join('\n')); + } + + // Group E: Fixes + if (fixBullets.length > 0) { + groups.push('### Fix\n' + fixBullets.join('\n')); + } + + // Group F: New Contributors + if (newContributorBullets.length > 0) { + groups.push('## New Contributors\n' + newContributorBullets.join('\n')); + } + + // Group G: Full Changelog + if (fullChangelogLine) { + groups.push(fullChangelogLine); + } + + return groups.join('\n\n'); +} + +// CLI entry point +if (require.main === module) { + try { + const argv = process.argv.slice(2); + + let tag = null; + let repo = null; + let packageName = null; + let prerelease = null; + let useStdin = false; + let doApply = false; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--tag') { + tag = argv[++i]; + } else if (arg === '--repo') { + repo = argv[++i]; + } else if (arg === '--package') { + packageName = argv[++i]; + } else if (arg === '--prerelease') { + prerelease = true; + } else if (arg === '--latest') { + prerelease = false; + } else if (arg === '--stdin') { + useStdin = true; + } else if (arg === '--apply') { + doApply = true; + } + } + + // Derive version from tag + const version = tag ? tag.replace(/^v/, '') : null; + + // Resolve package name if not provided + if (!packageName) { + const repoRoot = path.resolve(__dirname, '..', '..'); + const pkgJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + packageName = pkgJson.name; + } + + let generatedBody; + + if (useStdin) { + // Read from stdin + if (!version) { + throw new Error('--stdin mode requires --tag or --version to derive version'); + } + if (prerelease === null) { + throw new Error('--stdin mode requires --prerelease or --latest'); + } + generatedBody = fs.readFileSync('/dev/stdin', 'utf8'); + } else { + // Fetch from gh + if (!tag) { + throw new Error('--tag is required'); + } + + const ghArgs = ['release', 'view', tag, '--json', 'body', '-q', '.body']; + if (repo) ghArgs.push('--repo', repo); + + generatedBody = execFileSync('gh', ghArgs, { encoding: 'utf8' }); + + // Determine prerelease if not forced + if (prerelease === null) { + try { + const ghPreArgs = ['release', 'view', tag, '--json', 'isPrerelease', '-q', '.isPrerelease']; + if (repo) ghPreArgs.push('--repo', repo); + const result = execFileSync('gh', ghPreArgs, { encoding: 'utf8' }).trim(); + prerelease = result === 'true'; + } catch (_e) { + // Final fallback: check if tag contains `-` after version digits + prerelease = /-/.test(version); + } + } + } + + const formatted = formatReleaseNotes({ generatedBody, version, prerelease, packageName }); + + if (doApply) { + const tmpFile = path.join(os.tmpdir(), `release-notes-${Date.now()}.md`); + fs.writeFileSync(tmpFile, formatted, 'utf8'); + try { + const ghArgs = ['release', 'edit', tag, '--notes-file', tmpFile]; + if (repo) ghArgs.push('--repo', repo); + execFileSync('gh', ghArgs, { encoding: 'utf8' }); + process.stderr.write(`Release notes updated for ${tag}\n`); + } finally { + fs.unlinkSync(tmpFile); + } + } else { + process.stdout.write(formatted + '\n'); + } + } catch (err) { + process.stderr.write((err.message || String(err)) + '\n'); + process.exit(1); + } +} + +module.exports = { formatReleaseNotes, classifyTitle }; diff --git a/tests/format-github-release-notes.test.cjs b/tests/format-github-release-notes.test.cjs new file mode 100644 index 000000000..fa563ac3b --- /dev/null +++ b/tests/format-github-release-notes.test.cjs @@ -0,0 +1,220 @@ +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + formatReleaseNotes, + classifyTitle, +} = require('../scripts/release-notes/format-github-release-notes.cjs'); + +// --------------------------------------------------------------------------- +// classifyTitle +// --------------------------------------------------------------------------- + +describe('classifyTitle', () => { + test('returns Feature for feat(#39): title', () => { + assert.equal( + classifyTitle('* feat(#39): milestone-prefixed phase IDs by @trek-e in https://github.com/open-gsd/gsd-core/pull/565'), + 'Feature' + ); + }); + + test('returns Feature for feat: title', () => { + assert.equal( + classifyTitle('* feat: some feature by @trek-e in https://github.com/open-gsd/gsd-core/pull/1'), + 'Feature' + ); + }); + + test('returns Feature for feature(x): title', () => { + assert.equal( + classifyTitle('* feature(x): something by @trek-e in https://github.com/open-gsd/gsd-core/pull/2'), + 'Feature' + ); + }); + + test('returns Fix for fix(#1): title', () => { + assert.equal( + classifyTitle('* fix(#1): some fix by @trek-e in https://github.com/open-gsd/gsd-core/pull/3'), + 'Fix' + ); + }); + + test('returns Fix for fix: title', () => { + assert.equal( + classifyTitle('* fix: another fix by @trek-e in https://github.com/open-gsd/gsd-core/pull/4'), + 'Fix' + ); + }); + + test('returns Enhancement for chore(#2): title', () => { + assert.equal( + classifyTitle('* chore(#2): some chore by @trek-e in https://github.com/open-gsd/gsd-core/pull/5'), + 'Enhancement' + ); + }); + + test('returns Enhancement for docs: title', () => { + assert.equal( + classifyTitle('* docs: documentation update by @trek-e in https://github.com/open-gsd/gsd-core/pull/6'), + 'Enhancement' + ); + }); + + test('returns Enhancement for [codex] Rebrand', () => { + assert.equal( + classifyTitle('* [codex] Rebrand public docs as GSD Core by @jeremymcs in https://github.com/open-gsd/gsd-core/pull/524'), + 'Enhancement' + ); + }); + + test('returns Enhancement for plain title with no conventional prefix', () => { + assert.equal( + classifyTitle('* Main changes by @trek-e in https://github.com/open-gsd/gsd-core/pull/7'), + 'Enhancement' + ); + }); +}); + +// --------------------------------------------------------------------------- +// formatReleaseNotes +// --------------------------------------------------------------------------- + +const SAMPLE_BODY = `## What's Changed +* feat(#39): milestone-prefixed phase IDs by @trek-e in https://github.com/open-gsd/gsd-core/pull/565 +* fix(#557): milestone erased on update by @trek-e in https://github.com/open-gsd/gsd-core/pull/563 +* chore(#2): update dependencies by @trek-e in https://github.com/open-gsd/gsd-core/pull/560 + +## New Contributors +* @someone made their first contribution in https://github.com/open-gsd/gsd-core/pull/123 + +**Full Changelog**: https://github.com/open-gsd/gsd-core/compare/v1.2.0...v1.3.0-rc.1 +`; + +describe('formatReleaseNotes', () => { + test('prerelease=true: Install block contains @next and pre-release text', () => { + const out = formatReleaseNotes({ + generatedBody: SAMPLE_BODY, + version: '1.3.0-rc.1', + prerelease: true, + packageName: '@opengsd/gsd-core', + }); + + assert.ok(out.startsWith('## Install'), 'should start with ## Install'); + assert.ok(out.includes('This pre-release is published to npm under the `next` dist-tag.'), 'should mention next dist-tag'); + assert.ok(out.includes('npm i @opengsd/gsd-core@1.3.0-rc.1'), 'should contain versioned install'); + assert.ok(out.includes('@next'), 'should contain @next tag'); + }); + + test('prerelease=true: sections appear in correct order', () => { + const out = formatReleaseNotes({ + generatedBody: SAMPLE_BODY, + version: '1.3.0-rc.1', + prerelease: true, + packageName: '@opengsd/gsd-core', + }); + + const installIdx = out.indexOf('## Install'); + const whatsChangedIdx = out.indexOf("## What's Changed"); + const featureIdx = out.indexOf('### Feature'); + const enhancementIdx = out.indexOf('### Enhancement'); + const fixIdx = out.indexOf('### Fix'); + const newContribIdx = out.indexOf('## New Contributors'); + const fullChangelogIdx = out.indexOf('**Full Changelog**:'); + + assert.ok(installIdx < whatsChangedIdx, '## Install should precede ## Whats Changed'); + assert.ok(whatsChangedIdx < featureIdx, "## What's Changed should precede ### Feature"); + assert.ok(featureIdx < enhancementIdx, '### Feature should precede ### Enhancement'); + assert.ok(enhancementIdx < fixIdx, '### Enhancement should precede ### Fix'); + assert.ok(fixIdx < newContribIdx, '### Fix should precede ## New Contributors'); + assert.ok(newContribIdx < fullChangelogIdx, '## New Contributors should precede **Full Changelog**'); + }); + + test('prerelease=true: New Contributors block is preserved', () => { + const out = formatReleaseNotes({ + generatedBody: SAMPLE_BODY, + version: '1.3.0-rc.1', + prerelease: true, + packageName: '@opengsd/gsd-core', + }); + + assert.ok(out.includes('## New Contributors'), 'should contain ## New Contributors'); + assert.ok( + out.includes('* @someone made their first contribution'), + 'should preserve contributor bullet' + ); + }); + + test('prerelease=true: ends with Full Changelog line and no trailing newline', () => { + const out = formatReleaseNotes({ + generatedBody: SAMPLE_BODY, + version: '1.3.0-rc.1', + prerelease: true, + packageName: '@opengsd/gsd-core', + }); + + assert.ok( + out.endsWith('**Full Changelog**: https://github.com/open-gsd/gsd-core/compare/v1.2.0...v1.3.0-rc.1'), + 'should end with Full Changelog line' + ); + assert.ok(!out.endsWith('\n'), 'should have no trailing newline'); + }); + + test('prerelease=false: Install block uses @latest and omits pre-release sentence', () => { + const out = formatReleaseNotes({ + generatedBody: SAMPLE_BODY, + version: '1.3.0', + prerelease: false, + packageName: '@opengsd/gsd-core', + }); + + assert.ok(out.includes('@latest'), 'should contain @latest'); + assert.ok( + !out.includes('This pre-release is published'), + 'should not contain pre-release sentence' + ); + assert.ok(out.includes('npm i @opengsd/gsd-core@1.3.0'), 'should contain versioned install'); + }); + + test('empty-section omission: only fix bullets → no Feature or Enhancement headers', () => { + const fixOnlyBody = `## What's Changed +* fix(#1): only a fix by @trek-e in https://github.com/open-gsd/gsd-core/pull/1 + +**Full Changelog**: https://github.com/open-gsd/gsd-core/compare/v1.0.0...v1.0.1 +`; + + const out = formatReleaseNotes({ + generatedBody: fixOnlyBody, + version: '1.0.1', + prerelease: false, + packageName: '@opengsd/gsd-core', + }); + + assert.ok(out.includes('### Fix'), 'should contain ### Fix'); + assert.ok(!out.includes('### Feature'), 'should NOT contain ### Feature'); + assert.ok(!out.includes('### Enhancement'), 'should NOT contain ### Enhancement'); + }); + + test('ordering within a category is preserved', () => { + const twoFeatBody = `## What's Changed +* feat(#1): first feature by @trek-e in https://github.com/open-gsd/gsd-core/pull/1 +* feat(#2): second feature by @trek-e in https://github.com/open-gsd/gsd-core/pull/2 + +**Full Changelog**: https://github.com/open-gsd/gsd-core/compare/v1.0.0...v1.1.0 +`; + + const out = formatReleaseNotes({ + generatedBody: twoFeatBody, + version: '1.1.0', + prerelease: false, + packageName: '@opengsd/gsd-core', + }); + + const firstIdx = out.indexOf('feat(#1): first feature'); + const secondIdx = out.indexOf('feat(#2): second feature'); + assert.ok(firstIdx !== -1, 'first feature bullet should be present'); + assert.ok(secondIdx !== -1, 'second feature bullet should be present'); + assert.ok(firstIdx < secondIdx, 'first feature should appear before second feature'); + }); +});