docs(changelog): title every release and require one

Every release file now carries a title: two to six plain words naming the
change a user would remember it for. The generator refuses a release without
one, promote carries the title from unreleased.md, and the unreleased
template opens with a title line. The changelog-authoring skill and the
format README describe how to write it.

Claude-Session: https://claude.ai/code/session_01VqV56Hez25hTxXH4ipJfzH
This commit is contained in:
Bohdan Triapitsyn
2026-09-05 16:45:29 +03:00
parent 475dc5ecb1
commit a1bc1368ae
144 changed files with 325 additions and 154 deletions
+12 -5
View File
@@ -139,6 +139,7 @@ export const loadReleases = (directory) => {
}
const stem = name.slice(0, -3);
if (!release.version || !release.date) fail(release.file, 1, 'a release file needs version and date in its front matter');
if (!release.title) fail(release.file, 1, 'a release file needs a title in its front matter (two to six words naming its headline change)');
if (release.version !== stem) fail(release.file, 1, `version ${release.version} does not match the file name ${stem}`);
releases.push(release);
}
@@ -214,15 +215,20 @@ export const renderOutputs = (loaded) => ({
'changelog/index.json': renderIndex(loaded),
});
export const UNRELEASED_TEMPLATE = `## App
export const UNRELEASED_TEMPLATE = `---
title:
---
## App
## VS Code
`;
/**
* Turn `unreleased.md` into `<version>.md` dated `date`, and reset
* `unreleased.md` to the empty template. Refuses an empty unreleased file:
* a release with nothing to say is a mistake, not a release.
* Turn `unreleased.md` into `<version>.md` dated `date`, keeping its title,
* and reset `unreleased.md` to the empty template. Refuses an unreleased
* file without bullets or without a title: a release with nothing to say,
* or nothing to call it, is a mistake, not a release.
*/
export const promoteUnreleased = (directory, version, date) => {
if (!VERSION_PATTERN.test(version)) throw new Error(`version ${JSON.stringify(version)} is not x.y.z`);
@@ -234,8 +240,9 @@ export const promoteUnreleased = (directory, version, date) => {
const release = parseRelease(text, path.relative(process.cwd(), source));
const bullets = [...Object.values(release.app ?? {}), ...Object.values(release.vscode ?? {})].flat();
if (bullets.length === 0) throw new Error('changelog/unreleased.md has no bullets; write the release notes before releasing');
if (!release.title) throw new Error('changelog/unreleased.md has no title; add a `title:` line to its front matter before releasing');
const body = text.replace(/^---\n[\s\S]*?\n---\n/, '');
fs.writeFileSync(target, `---\nversion: ${version}\ndate: ${date}\n---\n\n${body.replace(/^\n+/, '')}`);
fs.writeFileSync(target, `---\nversion: ${version}\ndate: ${date}\ntitle: ${release.title}\n---\n\n${body.replace(/^\n+/, '')}`);
fs.writeFileSync(source, UNRELEASED_TEMPLATE);
return target;
};
+11 -6
View File
@@ -54,7 +54,7 @@ test('rejects shapes the generator cannot render', () => {
test('renders groups in canonical order with today\'s headers and skips versions without a VS Code section', () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'changelog-'));
fs.writeFileSync(path.join(directory, '1.2.3.md'), release);
fs.writeFileSync(path.join(directory, '1.2.4.md'), '---\nversion: 1.2.4\ndate: 2026-02-01\n---\n\n## App\n\n### Improvements\n- Faster.\n');
fs.writeFileSync(path.join(directory, '1.2.4.md'), '---\nversion: 1.2.4\ndate: 2026-02-01\ntitle: Faster\n---\n\n## App\n\n### Improvements\n- Faster.\n');
fs.writeFileSync(path.join(directory, 'unreleased.md'), '## App\n\n### Fixes\n- Pending fix.\n\n## VS Code\n');
const loaded = loadReleases(directory);
@@ -108,18 +108,23 @@ A short intro.
assert.equal(index[0].vscode, null);
});
test('loadReleases refuses a file whose name and version disagree', () => {
test('loadReleases refuses a file whose name and version disagree, and a release without a title', () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'changelog-'));
fs.writeFileSync(path.join(directory, '9.9.9.md'), release);
assert.throws(() => loadReleases(directory), /does not match the file name 9\.9\.9/);
fs.unlinkSync(path.join(directory, '9.9.9.md'));
fs.writeFileSync(path.join(directory, '1.0.0.md'), '---\nversion: 1.0.0\ndate: 2026-02-01\n---\n\n## App\n\n### Fixes\n- x.\n');
assert.throws(() => loadReleases(directory), /needs a title/);
});
test('promoteUnreleased dates the release, resets the template, and refuses an empty release', () => {
test('promoteUnreleased dates and titles the release, resets the template, and refuses an empty or untitled release', () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'changelog-'));
fs.writeFileSync(path.join(directory, 'unreleased.md'), '## App\n\n### New\n- Something shipped.\n\n## VS Code\n');
fs.writeFileSync(path.join(directory, 'unreleased.md'), '---\ntitle: Something shipped\n---\n\n## App\n\n### New\n- Something shipped.\n\n## VS Code\n');
const created = promoteUnreleased(directory, '2.0.0', '2026-03-01');
assert.equal(path.basename(created), '2.0.0.md');
assert.match(fs.readFileSync(created, 'utf8'), /^---\nversion: 2\.0\.0\ndate: 2026-03-01\n---\n\n## App/);
assert.equal(fs.readFileSync(path.join(directory, 'unreleased.md'), 'utf8'), '## App\n\n## VS Code\n');
assert.match(fs.readFileSync(created, 'utf8'), /^---\nversion: 2\.0\.0\ndate: 2026-03-01\ntitle: Something shipped\n---\n\n## App/);
assert.equal(fs.readFileSync(path.join(directory, 'unreleased.md'), 'utf8'), '---\ntitle:\n---\n\n## App\n\n## VS Code\n');
assert.throws(() => promoteUnreleased(directory, '2.0.1', '2026-03-02'), /has no bullets/);
fs.writeFileSync(path.join(directory, 'unreleased.md'), '## App\n\n### New\n- Untitled.\n');
assert.throws(() => promoteUnreleased(directory, '2.0.1', '2026-03-02'), /has no title/);
});