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
2 changes: 1 addition & 1 deletion .releaserc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
["@semantic-release/npm", { "npmPublish": true }],
["@semantic-release/git", {
"assets": ["package.json", "CHANGELOG.md"],
Expand Down
18 changes: 9 additions & 9 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
# Changelog
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

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]

## [1.1.2](https://github.com/WYRE-AI/node-halopsa/compare/v1.1.1...v1.1.2) (2026-10-16)


Expand Down Expand Up @@ -136,15 +145,6 @@

* Initial implementation of node-halopsa TypeScript library ([f16341d](https://github.com/asachs01/node-halopsa/commit/f16341dfd19dff3dc3f316d4853ac99cef5e6338))

# 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
Expand Down
115 changes: 86 additions & 29 deletions scripts/prepare-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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();
}
86 changes: 86 additions & 0 deletions tests/unit/prepare-release.test.ts
Original file line number Diff line number Diff line change
@@ -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)'));
});
});
Loading