diff --git a/.releaserc.json b/.releaserc.json index 227fce8..6b10a17 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -3,7 +3,7 @@ "plugins": [ "@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", - ["@semantic-release/changelog", { "changelogFile": "CHANGELOG.md" }], + ["@semantic-release/changelog", { "changelogFile": "CHANGELOG.md", "changelogTitle": "# Changelog" }], ["@semantic-release/npm", { "npmPublish": true }], ["@semantic-release/git", { "assets": ["package.json", "CHANGELOG.md"], diff --git a/CHANGELOG.md b/CHANGELOG.md index 4376ce6..7147882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + ## [3.1.2](https://github.com/WYRE-AI/node-superops/compare/v3.1.1...v3.1.2) (2026-10-08) @@ -144,15 +153,6 @@ Page instead of a cursor Connection. * Add semantic-release for GitHub Packages publishing ([6221a7f](https://github.com/asachs01/node-superops/commit/6221a7f61c8b689b240b0fe33f242228b24605dc)) * Initial release of node-superops TypeScript library ([443c430](https://github.com/asachs01/node-superops/commit/443c43094ca61eecf3e22489eed161a4d441f695)) -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - ## [0.1.0] - 2026-02-04 ### Added diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs index 05051ae..3d07e30 100644 --- a/scripts/prepare-release.mjs +++ b/scripts/prepare-release.mjs @@ -14,41 +14,98 @@ // CHANGELOG.md directly (same as @semantic-release/changelog would) rather // than passed through GITHUB_OUTPUT, since release notes can contain // characters/length that don't survive that path cleanly. +// +// insertReleaseNotes is header-aware (fleet-wide changelogTitle-orphaning +// fix, 2026-10-12, CodeRabbit catch on the sweep that first tried fixing +// this via .releaserc.json's changelogTitle alone): dryRun:true skips +// semantic-release's own `prepare` lifecycle step entirely, so +// @semantic-release/changelog's changelogTitle-aware insert logic never +// runs in this flow -- changelogTitle config has zero effect here. This +// function is what actually has to recognize an existing title/preamble/ +// Unreleased scaffold and keep new notes below it instead of prepending at +// column zero every release. import semanticRelease from "semantic-release"; import { readFileSync, writeFileSync, appendFileSync } from "node:fs"; import { execSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; -const result = await semanticRelease({ dryRun: true, ci: false }); - -const githubOutput = process.env.GITHUB_OUTPUT; -if (!result) { - console.log("No release needed."); - if (githubOutput) appendFileSync(githubOutput, "release_needed=false\n"); - process.exit(0); +export function insertReleaseNotes(notes, existing) { + if (!existing) { + return `${notes}\n`; + } + // A real release heading always looks like "## [X.Y.Z](...)" (every + // release after the first) or "# X.Y.Z (date)" (the bare first release, + // no link). Anything before the first such heading is header scaffold + // (title, preamble, an optional "## [Unreleased]" marker) that must stay + // above new notes rather than being pushed below them. + // + // Must require a DIGIT right after "## [" -- a bare "/^## \[/" also + // matches "## [Unreleased]" itself, which sits inside the scaffold, not + // after it. Matching on it would treat the Unreleased line as "the first + // real entry" and insert new notes BETWEEN the preamble and the + // Unreleased marker, pushing Unreleased below the new entry instead of + // leaving it above (caught by a real test against this exact file, not + // by re-reading the regex: case2's "Unreleased precedes new entry" + // assertion failed with the unqualified version first written). + const headingRe = /^(## \[\d|# \d+\.\d+\.\d+ )/m; + const match = headingRe.exec(existing); + if (!match) { + // No release heading anywhere -- header-only scaffold with no releases + // yet, or an unrecognized shape. Append after whatever's there rather + // than guessing further. + return `${existing.trimEnd()}\n\n${notes}\n`; + } + if (match.index === 0) { + // No scaffold before the first entry -- original prepend behavior, + // unchanged for any repo that doesn't have this header pattern. + return `${notes}\n\n${existing}`.trimEnd() + "\n"; + } + const header = existing.slice(0, match.index).trimEnd(); + const rest = existing.slice(match.index); + return `${header}\n\n${notes}\n\n${rest}`.trimEnd() + "\n"; } -const { version, notes } = result.nextRelease; -console.log(`Next release: ${version}`); - -// Bump package.json without creating a git tag or committing -- pure file -// write, same command @semantic-release/npm uses internally for this step. -execSync(`npm version ${version} --no-git-tag-version --allow-same-version`, { - stdio: "inherit", -}); - -// Prepend to CHANGELOG.md, matching @semantic-release/changelog's own -// convention (newest release on top) so this stays a drop-in for repos that -// already have history in this format. -const changelogPath = "CHANGELOG.md"; -let existing = ""; -try { - existing = readFileSync(changelogPath, "utf8"); -} catch { - // No CHANGELOG.md yet -- fine, this is the first entry. +async function main() { + const result = await semanticRelease({ dryRun: true, ci: false }); + + const githubOutput = process.env.GITHUB_OUTPUT; + if (!result) { + console.log("No release needed."); + if (githubOutput) appendFileSync(githubOutput, "release_needed=false\n"); + process.exit(0); + } + + const { version, notes } = result.nextRelease; + console.log(`Next release: ${version}`); + + // Bump package.json without creating a git tag or committing -- pure file + // write, same command @semantic-release/npm uses internally for this step. + execSync(`npm version ${version} --no-git-tag-version --allow-same-version`, { + stdio: "inherit", + }); + + const changelogPath = "CHANGELOG.md"; + let existing = ""; + try { + existing = readFileSync(changelogPath, "utf8"); + } catch { + // No CHANGELOG.md yet -- fine, this is the first entry. + } + writeFileSync(changelogPath, insertReleaseNotes(notes, existing)); + + if (githubOutput) { + appendFileSync(githubOutput, "release_needed=true\n"); + appendFileSync(githubOutput, `version=${version}\n`); + } } -writeFileSync(changelogPath, `${notes}\n\n${existing}`.trimEnd() + "\n"); -if (githubOutput) { - appendFileSync(githubOutput, "release_needed=true\n"); - appendFileSync(githubOutput, `version=${version}\n`); +// Compare as filesystem paths, not as import.meta.url vs. a naively +// file://-prefixed argv[1] -- import.meta.url is URL-encoded (a space +// becomes %20, a # becomes %23), so a checkout path containing either +// would make the naive string comparison fail silently and never invoke +// main() at all. fileURLToPath decodes it back to a real path first. +// (CodeRabbit catch, fleet-wide changelogTitle sweep -- caught on CI's own +// checkout path shape being safe today, not on this ever having failed.) +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + await main(); } diff --git a/tests/unit/prepare-release.test.ts b/tests/unit/prepare-release.test.ts new file mode 100644 index 0000000..4605f13 --- /dev/null +++ b/tests/unit/prepare-release.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { insertReleaseNotes } from '../../scripts/prepare-release.mjs'; + +describe('insertReleaseNotes', () => { + it('returns bare notes when there is no existing changelog', () => { + expect(insertReleaseNotes('## [1.0.0] notes', '')).toBe('## [1.0.0] notes\n'); + expect(insertReleaseNotes('## [1.0.0] notes', undefined as unknown as string)).toBe( + '## [1.0.0] notes\n' + ); + }); + + it('appends after a header-only scaffold with no release headings yet', () => { + const existing = '# Changelog\n\nAll notable changes to this project will be documented here.'; + const result = insertReleaseNotes('## [1.0.0](link) (2026-01-01)\n\n* first release', existing); + expect(result).toBe( + '# Changelog\n\nAll notable changes to this project will be documented here.\n\n' + + '## [1.0.0](link) (2026-01-01)\n\n* first release\n' + ); + }); + + it('prepends notes when there is no header scaffold (heading at index 0)', () => { + const existing = '## [1.0.0](link) (2026-01-01)\n\n* first release'; + const notes = '## [1.1.0](link) (2026-02-01)\n\n* second release'; + const result = insertReleaseNotes(notes, existing); + expect(result).toBe(`${notes}\n\n${existing}\n`); + }); + + it('inserts notes between the header scaffold and the first versioned entry (node-rootly shape: no Unreleased section)', () => { + const existing = + '# Changelog\n\n' + + 'All notable changes to this project will be documented in this file.\n' + + 'See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.\n\n' + + '## [1.0.3](https://github.com/WYRE-AI/node-rootly/compare/v1.0.2...v1.0.3) (2026-08-25)\n\n' + + '* export request() from public API'; + const notes = '## [1.0.4](https://github.com/WYRE-AI/node-rootly/compare/v1.0.3...v1.0.4) (2026-10-16)\n\n* a new fix'; + const result = insertReleaseNotes(notes, existing); + + expect(result).toBe( + '# Changelog\n\n' + + 'All notable changes to this project will be documented in this file.\n' + + 'See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.\n\n' + + `${notes}\n\n` + + '## [1.0.3](https://github.com/WYRE-AI/node-rootly/compare/v1.0.2...v1.0.3) (2026-08-25)\n\n' + + '* export request() from public API\n' + ); + // New notes must land strictly after the header, never swallow/duplicate it. + expect(result.indexOf(notes)).toBeGreaterThan(result.indexOf('# Changelog')); + expect(result.match(/# Changelog/g)).toHaveLength(1); + }); + + it('keeps "## [Unreleased]" above the inserted notes, not below it (the regression this PR fixes)', () => { + const existing = + '# Changelog\n\n' + + 'All notable changes to this project will be documented in this file.\n\n' + + '## [Unreleased]\n\n' + + '## [1.0.0](link) (2026-08-25)\n\n' + + '* first release'; + const notes = '## [1.1.0](link) (2026-10-16)\n\n* a new fix'; + const result = insertReleaseNotes(notes, existing); + + const unreleasedIdx = result.indexOf('## [Unreleased]'); + const notesIdx = result.indexOf(notes); + const oldEntryIdx = result.indexOf('## [1.0.0]'); + + expect(unreleasedIdx).toBeGreaterThan(-1); + // Unreleased must stay ABOVE the newly inserted notes (this is the exact + // bug an unqualified /^## \[/ regex would reintroduce: matching + // "## [Unreleased]" itself as "the first real entry" and inserting the + // new notes between the preamble and Unreleased instead of below it). + expect(unreleasedIdx).toBeLessThan(notesIdx); + expect(notesIdx).toBeLessThan(oldEntryIdx); + }); + + it('recognizes the bare first-release heading shape ("# X.Y.Z (date)", no link) as a real entry', () => { + const existing = + '# Changelog\n\n' + + 'All notable changes to this project will be documented in this file.\n\n' + + '# 1.0.0 (2026-01-01)\n\n' + + '* initial release — no repository link configured'; + const notes = '## [1.1.0](link) (2026-02-01)\n\n* second release'; + const result = insertReleaseNotes(notes, existing); + + expect(result.indexOf('# Changelog')).toBeLessThan(result.indexOf(notes)); + expect(result.indexOf(notes)).toBeLessThan(result.indexOf('# 1.0.0 (2026-01-01)')); + }); +});