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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user