Skip to content

Commit f41f1bf

Browse files
fix(release): remove empty unreleased changelog headings
1 parent 4bf46ad commit f41f1bf

3 files changed

Lines changed: 83 additions & 2 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
55

66
Starting from 0.2.0, CLI / Extension / DSH Plugin share the same version number.
77

8-
## [Unreleased]
9-
108
## [0.3.0] - 2026-09-16
119

1210
### Added

‎scripts/release.mjs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,10 @@ function updateChangelog(version) {
193193
}
194194
}
195195

196+
// Remove an empty development placeholder after preparing the release.
197+
// Keep future release notes when this version already has a prepared entry.
198+
content = content.replace(/^## \[Unreleased\][ \t]*(?:\r?\n[ \t]*)*(?=^## |(?![\s\S]))/m, "");
199+
196200
return { path: changelogPath, content };
197201
}
198202

‎scripts/release.test.mjs‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import assert from "node:assert/strict";
2+
import { execFileSync } from "node:child_process";
3+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import test from "node:test";
7+
import { fileURLToPath } from "node:url";
8+
9+
const scriptPath = fileURLToPath(new URL("./release.mjs", import.meta.url));
10+
11+
function prepareRelease(t, changelog, extraArgs = []) {
12+
const cwd = mkdtempSync(join(tmpdir(), "bsk-release-test-"));
13+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
14+
for (const dir of ["apps/extension", "packages/dsh-plugin-browserskill", "bin"]) {
15+
mkdirSync(join(cwd, dir), { recursive: true });
16+
}
17+
writeFileSync(join(cwd, "Cargo.toml"), '[workspace.package]\nversion = "0.2.1"\n');
18+
for (const dir of ["apps/extension", "packages/dsh-plugin-browserskill"]) {
19+
writeFileSync(join(cwd, dir, "package.json"), '{"version":"0.2.1"}\n');
20+
}
21+
writeFileSync(join(cwd, "CHANGELOG.md"), changelog);
22+
// Exercise the real file-writing path without compiling Rust or creating Git tags.
23+
const windows = process.platform === "win32";
24+
writeFileSync(
25+
join(cwd, "bin", windows ? "cargo.cmd" : "cargo"),
26+
windows ? "@exit /b 0\r\n" : "#!/bin/sh\nexit 0\n",
27+
{ mode: 0o755 },
28+
);
29+
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH";
30+
execFileSync(process.execPath, [scriptPath, "0.3.0", "--no-commit", ...extraArgs], {
31+
cwd,
32+
env: {
33+
...process.env,
34+
[pathKey]: `${join(cwd, "bin")}${windows ? ";" : ":"}${process.env[pathKey] ?? ""}`,
35+
},
36+
});
37+
return readFileSync(join(cwd, "CHANGELOG.md"), "utf8");
38+
}
39+
40+
const history = "## [0.2.1] - 2026-09-09\n\n### Fixed\n\n- Previous fix.\n";
41+
const notes = "### Added\n\n- New release feature.\n\n";
42+
const prepared = `## [0.3.0] - 2026-09-16\n\n${notes}${history}`;
43+
44+
test("moves unreleased notes into the release without an empty heading", (t) => {
45+
const actual = prepareRelease(t, `# Changelog\n\n## [Unreleased]\n\n${notes}${history}`);
46+
assert.match(actual, /^# Changelog\n\n## \[0\.3\.0\] - \d{4}-\d{2}-\d{2}\n\n/);
47+
assert.ok(actual.endsWith(`${notes}${history}`));
48+
assert.doesNotMatch(actual, /\[Unreleased\]/);
49+
});
50+
51+
test("removes an empty heading without changing a prepared release", (t) => {
52+
const actual = prepareRelease(t, `# Changelog\n\n## [Unreleased]\n \t\n${prepared}`);
53+
assert.equal(actual, `# Changelog\n\n${prepared}`);
54+
});
55+
56+
test("preserves future unreleased notes when the target release is already prepared", (t) => {
57+
const changelog = `# Changelog\n\n## [Unreleased]\n\n### Changed\n\n- Future feature.\n\n${prepared}`;
58+
assert.equal(prepareRelease(t, changelog), changelog);
59+
});
60+
61+
test("fills a prepared release date and removes its empty unreleased placeholder", (t) => {
62+
const actual = prepareRelease(
63+
t,
64+
`# Changelog\n\n## [Unreleased]\n\n## [0.3.0] - 2026-09-XX\n\n${notes}${history}`,
65+
);
66+
assert.match(actual, /^# Changelog\n\n## \[0\.3\.0\] - \d{4}-\d{2}-\d{2}\n\n/);
67+
assert.ok(actual.endsWith(`${notes}${history}`));
68+
assert.doesNotMatch(actual, /\[Unreleased\]/);
69+
});
70+
71+
test("leaves a prepared release unchanged when there is no unreleased heading", (t) => {
72+
const changelog = `# Changelog\n\n${prepared}`;
73+
assert.equal(prepareRelease(t, changelog), changelog);
74+
});
75+
76+
test("dry run preserves the original changelog", (t) => {
77+
const changelog = `# Changelog\n\n## [Unreleased]\n\n${notes}${history}`;
78+
assert.equal(prepareRelease(t, changelog, ["--dry-run"]), changelog);
79+
});

0 commit comments

Comments
 (0)