diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd380450..a4e3b6b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -246,7 +246,11 @@ jobs: args: .github/workflows/release-please.yml .github/workflows/ci.yml - name: Syntax check - run: node --check scripts/credit-changelog-contributors.mjs + run: | + node --check scripts/credit-changelog-contributors.mjs + node --check scripts/release-notes-from-changelog.mjs - name: Unit tests - run: node --test scripts/credit-changelog-contributors.test.mjs + run: | + node --test scripts/credit-changelog-contributors.test.mjs + node --test scripts/release-notes-from-changelog.test.mjs diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 997ab674..a557b951 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -58,6 +58,21 @@ jobs: config-file: release-please-config.json manifest-file: .release-please-manifest.json + # release-please stores the release notes in the release PR body when it + # opens that PR, and builds the GitHub Release from that stored body at + # merge time. The credit step below runs afterwards and only rewrites + # CHANGELOG.md on the branch, so credits never reach the published notes. + # Re-publish them from CHANGELOG.md, which by now carries the credits. + # Runs before the credit step, which checks out the next release branch + # and would otherwise leave the wrong CHANGELOG.md in the working tree. + - name: Sync published release notes with credited changelog + if: ${{ steps.release.outputs.releases_created == 'true' }} + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + TAG="$(node scripts/release-notes-from-changelog.mjs "$RUNNER_TEMP/release-notes.md")" + gh release edit "$TAG" --notes-file "$RUNNER_TEMP/release-notes.md" + - name: Credit external contributors in changelog if: ${{ steps.release.outputs.prs_created == 'true' }} env: diff --git a/scripts/release-notes-from-changelog.mjs b/scripts/release-notes-from-changelog.mjs new file mode 100644 index 00000000..8353316d --- /dev/null +++ b/scripts/release-notes-from-changelog.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node + +/** + * Emits the newest CHANGELOG.md version section so a published GitHub Release + * can be re-synced from it. + * + * release-please writes the release notes into the release pull request body + * when it opens that PR, and builds the GitHub Release from that stored body + * at merge time. The contributor credit pass runs after the PR body is + * written and only rewrites CHANGELOG.md on the branch, so credits reach the + * repository changelog but never the published release notes. Anything + * reading release bodies (the releases page, the website changelog) therefore + * shows uncredited text. + * + * Re-publishing the notes from CHANGELOG.md closes that gap. The two are + * otherwise byte-identical, so this is idempotent when there is nothing to + * credit. + * + * Usage: + * node scripts/release-notes-from-changelog.mjs + * + * Writes the section body to and prints the matching tag + * (e.g. "v0.97.0") to stdout, so the caller can pair the two: + * + * TAG=$(node scripts/release-notes-from-changelog.mjs notes.md) + * gh release edit "$TAG" --notes-file notes.md + * + * Exits nonzero without writing if the newest section cannot be located or + * its heading carries no parsable version. + */ + +import { readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' + +import { parseVersionSection } from './credit-changelog-contributors.mjs' + +/** Read the version out of a "## [1.2.3](compare-url) (date)" heading. */ +export function versionFromHeading(sectionText) { + const match = sectionText.match(/^##\s+\[([\d.]+)]/) + return match ? match[1] : null +} + +export function main(argv) { + const notesPath = argv[2] + if (!notesPath) { + console.error('Usage: release-notes-from-changelog.mjs ') + process.exit(1) + } + + const changelogPath = resolve(process.cwd(), 'CHANGELOG.md') + let text + try { + text = readFileSync(changelogPath, 'utf-8') + } catch (err) { + console.error(`Cannot read CHANGELOG.md: ${err.message}`) + process.exit(1) + } + + const section = parseVersionSection(text) + if (!section) { + console.error('No version heading found in CHANGELOG.md') + process.exit(1) + } + + const notes = text.slice(section.start, section.end).trim() + const version = versionFromHeading(notes) + if (!version) { + console.error('Newest CHANGELOG.md section has no parsable version heading') + process.exit(1) + } + + writeFileSync(resolve(process.cwd(), notesPath), `${notes}\n`, 'utf-8') + process.stdout.write(`v${version}\n`) +} + +const runningDirectly = + process.argv[1] && + (process.argv[1].endsWith('release-notes-from-changelog.mjs') || + process.argv[1].endsWith('release-notes-from-changelog')) + +if (runningDirectly) { + main(process.argv) +} diff --git a/scripts/release-notes-from-changelog.test.mjs b/scripts/release-notes-from-changelog.test.mjs new file mode 100644 index 00000000..5947de87 --- /dev/null +++ b/scripts/release-notes-from-changelog.test.mjs @@ -0,0 +1,152 @@ +/** + * Tests for scripts/release-notes-from-changelog.mjs + * + * Run: node --test scripts/release-notes-from-changelog.test.mjs + * + * Fixtures are inline strings, except the end-to-end cases which write a + * CHANGELOG.md into a temp directory and run main() with cwd pointed at it. + * main() is never invoked on import because the CLI guard fires. + */ + +import { describe, it, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { versionFromHeading, main } from './release-notes-from-changelog.mjs' + +const PREAMBLE = [ + '# Changelog', + '', + 'All notable changes are documented here.', + '', +].join('\n') + +function section(version, date, bullets) { + return [ + `## [${version}](https://github.com/Studio-Saelix/sencho/compare/v0.0.0...v${version}) (${date})`, + '', + '', + '### Added', + '', + ...bullets, + '', + '', + ].join('\n') +} + +// --------------------------------------------------------------------------- +// versionFromHeading +// --------------------------------------------------------------------------- + +describe('versionFromHeading', () => { + it('reads the version out of a release-please heading', () => { + const heading = + '## [0.97.0](https://github.com/Studio-Saelix/sencho/compare/v0.96.0...v0.97.0) (2026-08-06)' + assert.equal(versionFromHeading(heading), '0.97.0') + }) + + it('reads a patch version', () => { + const heading = + '## [0.94.1](https://github.com/Studio-Saelix/sencho/compare/v0.94.0...v0.94.1) (2026-07-06)' + assert.equal(versionFromHeading(heading), '0.94.1') + }) + + it('returns null when the heading is not a version heading', () => { + assert.equal(versionFromHeading('### Added\n\n* something'), null) + }) + + it('returns null when the version heading is not at the start', () => { + assert.equal(versionFromHeading('intro\n## [0.97.0](url) (2026-08-06)'), null) + }) +}) + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + +describe('main', () => { + let dir + let cwd + let stdout + let written + + function run(changelog, notesName = 'notes.md') { + dir = mkdtempSync(join(tmpdir(), 'release-notes-')) + writeFileSync(join(dir, 'CHANGELOG.md'), changelog, 'utf-8') + cwd = process.cwd() + process.chdir(dir) + + written = '' + stdout = process.stdout.write + process.stdout.write = (chunk) => { + written += chunk + return true + } + + try { + main(['node', 'release-notes-from-changelog.mjs', notesName]) + } finally { + process.stdout.write = stdout + } + + return { + tag: written.trim(), + notes: readFileSync(join(dir, notesName), 'utf-8'), + } + } + + afterEach(() => { + if (cwd) process.chdir(cwd) + if (dir) rmSync(dir, { recursive: true, force: true }) + dir = undefined + cwd = undefined + }) + + it('emits the newest section and its tag', () => { + const changelog = + PREAMBLE + + section('0.97.0', '2026-08-06', ['* newest change']) + + section('0.96.0', '2026-07-26', ['* older change']) + + const { tag, notes } = run(changelog) + + assert.equal(tag, 'v0.97.0') + assert.match(notes, /^## \[0\.97\.0]/) + assert.match(notes, /newest change/) + assert.doesNotMatch(notes, /older change/) + }) + + it('preserves inline contributor credits verbatim', () => { + const changelog = + PREAMBLE + + section('0.97.0', '2026-08-06', [ + '* account for VM memory ballooning ([#1750](https://github.com/Studio-Saelix/sencho/issues/1750)), thanks @Crosis47', + '* auto-update stacks by Stack Label ([#1717](https://github.com/Studio-Saelix/sencho/issues/1717)), thanks @Sn00zEZA', + ]) + + const { notes } = run(changelog) + + assert.match(notes, /, thanks @Crosis47$/m) + assert.match(notes, /, thanks @Sn00zEZA$/m) + }) + + it('excludes the Keep a Changelog preamble', () => { + const changelog = PREAMBLE + section('0.97.0', '2026-08-06', ['* a change']) + + const { notes } = run(changelog) + + assert.doesNotMatch(notes, /All notable changes/) + assert.doesNotMatch(notes, /^# Changelog/m) + }) + + it('handles a changelog with a single version section', () => { + const changelog = PREAMBLE + section('0.1.0', '2026-01-01', ['* first release']) + + const { tag, notes } = run(changelog) + + assert.equal(tag, 'v0.1.0') + assert.match(notes, /first release/) + }) +})