From 950a5956bb06d97397ceadda0549156fb4f80ff3 Mon Sep 17 00:00:00 2001 From: Aaron Sachs <898627+asachs01@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:04:57 +0000 Subject: [PATCH 1/4] fix(release): add changelogTitle config to prevent changelog header orphaning @semantic-release/changelog's prepare.js never recognized the existing Keep-a-Changelog header because changelogTitle was unset, so it prepended each new release at column zero and pushed the title/preamble/Unreleased block further down the file on every release. Set changelogTitle so future releases are inserted below the header instead of above it, and move the already-orphaned header block back to the top of CHANGELOG.md. Same pattern as WYRE-AI/node-domotz#49. Co-Authored-By: Claude Sonnet 5 --- .releaserc.json | 2 +- CHANGELOG.md | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) 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 From 3eda3dac744d7550e3dda4c44a35cfcf4735976e Mon Sep 17 00:00:00 2001 From: Aaron Sachs <898627+asachs01@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:30:25 +0000 Subject: [PATCH 2/4] fix(release): make CHANGELOG.md insertion header-aware in prepare-release.mjs The prior commit on this branch added changelogTitle to .releaserc.json, but this repo's real release mechanism (prepare-release.mjs, invoked by release.yml's PREPARE-mode step) calls semanticRelease({ dryRun: true }), which skips semantic-release's own prepare lifecycle step entirely -- so @semantic-release/changelog's changelogTitle-aware insert logic never runs here. changelogTitle was inert; the header-position fix would have been undone on the very next release through this pipeline. insertReleaseNotes() now detects an existing title/preamble/Unreleased scaffold (by finding the first real release heading -- "## [" or a bare "# X.Y.Z " -- and treating anything before it as scaffold to preserve above new notes) and inserts new release notes below that scaffold instead of at column zero. Falls back to the original unqualified prepend for any repo/state with no such scaffold, so this is a no-op for every other case. Verified against this exact file (not a parallel reimplementation): ran a real test importing insertReleaseNotes from this script with semantic-release actually installed, covering 6 cases (no-header-repo parity with the original prepend, header+Unreleased, header-without- Unreleased, header-only/no-releases-yet, brand-new-repo/no-existing-file, and a repeated-release idempotence check) -- all pass. Also ran this exact script directly (node scripts/prepare-release.mjs) against this branch to confirm the refactor (wrapping main() behind an import.meta.url guard so insertReleaseNotes is importable without triggering a real dry run) didn't change the script's own real behavior. Found via CodeRabbit's CHANGES_REQUESTED on the prior commit -- flagging a real gap in the fleet-wide changelogTitle sweep's original diagnosis. --- scripts/prepare-release.mjs | 107 ++++++++++++++++++++++++++---------- 1 file changed, 78 insertions(+), 29 deletions(-) diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs index 05051ae..1141640 100644 --- a/scripts/prepare-release.mjs +++ b/scripts/prepare-release.mjs @@ -14,41 +14,90 @@ // 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"; -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`); +if (import.meta.url === `file://${process.argv[1]}`) { + await main(); } From 72e0b8406b3be58c84990fbc375f116660eed102 Mon Sep 17 00:00:00 2001 From: Aaron Sachs <898627+asachs01@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:38:13 +0000 Subject: [PATCH 3/4] fix(release): decode import.meta.url before comparing to argv[1] CodeRabbit catch on the previous commit: the direct-execution guard compared import.meta.url (URL-encoded -- a space becomes %20, a # becomes %23) against a naively file://-prefixed process.argv[1] (a raw filesystem path, never encoded). A checkout path containing either character would make the comparison silently false and never invoke main() at all. Reproduced directly (a path with a space: encoded comparison -> false, fileURLToPath-based comparison -> true) before applying the fix, not just read-and-trusted. CI's own checkout path doesn't contain either character today, so this was latent, not a live failure -- fixing it anyway since the safe form costs nothing. --- scripts/prepare-release.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs index 1141640..3d07e30 100644 --- a/scripts/prepare-release.mjs +++ b/scripts/prepare-release.mjs @@ -27,6 +27,7 @@ import semanticRelease from "semantic-release"; import { readFileSync, writeFileSync, appendFileSync } from "node:fs"; import { execSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; export function insertReleaseNotes(notes, existing) { if (!existing) { @@ -98,6 +99,13 @@ async function main() { } } -if (import.meta.url === `file://${process.argv[1]}`) { +// 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(); } From 1c33f6e37b6a5713e8a3119fec2a5999939eafb3 Mon Sep 17 00:00:00 2001 From: "wyre-agent-fleet[bot]" <305944668+wyre-agent-fleet[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:52:08 +0000 Subject: [PATCH 4/4] test(release): cover insertReleaseNotes heading-detection branches --- tests/unit/prepare-release.test.ts | 86 ++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tests/unit/prepare-release.test.ts 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)')); + }); +});