chore(changelog): retire CHANGELOG.md as a dependency

The update dialog (server and desktop) now reads changelog/index.json and
renders title, intro and groups; the release workflow builds the GitHub
Release body and name from changelog/<version>.md; oc-dev, the issue-intake
agent, AGENTS.md and the changelog skill no longer point at the file.

CHANGELOG.md stays as a legacy copy for installs up to 1.22.1, which fetch
it for update notes. The generator refreshes it while it exists and never
recreates it, so deleting it after 2026-09-19 retires it for good.

Generated outputs hold released versions only, so editing unreleased.md
never makes them stale: agents write that file and nothing else, and
oc-dev create-release does the generation.

Claude-Session: https://claude.ai/code/session_01VqV56Hez25hTxXH4ipJfzH
This commit is contained in:
Bohdan Triapitsyn
2026-09-05 17:02:28 +03:00
parent c3ae9b1d91
commit 3d2bcf7ed6
16 changed files with 260 additions and 163 deletions
+6 -3
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env node
// Render the changelog outputs from `changelog/*.md`.
// Render the changelog outputs from `changelog/*.md`. `oc-dev create-release`
// is the normal caller; agents only edit `changelog/unreleased.md`.
//
// node scripts/changelog/build.mjs write CHANGELOG.md, packages/vscode/CHANGELOG.md, changelog/index.json
// node scripts/changelog/build.mjs write packages/vscode/CHANGELOG.md, changelog/index.json (and CHANGELOG.md while it exists)
// node scripts/changelog/build.mjs --check exit 1 when any output differs from what is committed
// node scripts/changelog/build.mjs --release 1.2.3 [--date YYYY-MM-DD]
// move unreleased.md to 1.2.3.md (dated today by default), then write
@@ -29,7 +30,9 @@ try {
console.log(`Promoted changelog/unreleased.md to ${path.relative(repoRoot, created)}`);
}
const outputs = renderOutputs(loadReleases(changelogDirectory));
// The legacy app changelog is refreshed while it exists and never recreated.
const legacyAppChangelog = fs.existsSync(path.join(repoRoot, 'CHANGELOG.md'));
const outputs = renderOutputs(loadReleases(changelogDirectory), { legacyAppChangelog });
const stale = [];
for (const [relativePath, content] of Object.entries(outputs)) {
const target = path.join(repoRoot, relativePath);
+33 -28
View File
@@ -1,19 +1,21 @@
// Source of truth for release notes: one Markdown file per release under
// `changelog/`, plus `changelog/unreleased.md` for what is not shipped yet.
// This module parses those files, validates their shape, and renders the
// three generated outputs: `CHANGELOG.md` (app), `packages/vscode/CHANGELOG.md`
// (extension, read by the Marketplace as is), and `changelog/index.json`.
// generated outputs: `packages/vscode/CHANGELOG.md` (extension, read by the
// Marketplace as is) and `changelog/index.json` (website and update dialog).
// Only released versions are rendered; `unreleased.md` is read straight from
// the source by whoever needs it, so editing it never makes an output stale.
//
// The generated Markdown keeps today's `## [x.y.z] - YYYY-MM-DD` headers: the
// update dialog, the release workflow, and the release script match them by
// regex. Groups render as `### New` / `### Improvements` / `### Fixes` /
// `### Misc` inside each release.
// `CHANGELOG.md` is legacy: app versions up to 1.22.1 fetch it from `main` for
// their update notes. It is refreshed only while it exists and is never
// recreated, so deleting it retires it for good.
import fs from 'node:fs';
import path from 'node:path';
export const GROUPS = ['New', 'Improvements', 'Fixes', 'Misc'];
const GENERATED_BANNER = '<!-- Generated from changelog/*.md by `bun run changelog:build`. Edit those files, not this one. -->';
const GENERATED_BANNER = '<!-- Generated from changelog/*.md by `oc-dev create-release`. Edit those files, not this one. -->';
const LEGACY_BANNER = '<!-- Legacy copy for app versions up to 1.22.1, which fetch this file for their update notes. Generated from changelog/*.md while it exists; delete it after 2026-09-19 and nothing will recreate it. -->';
export const SURFACES = ['App', 'VS Code'];
const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
@@ -172,23 +174,20 @@ const renderSection = (release, groups, intro) => {
return parts.join('\n').replace(/\n+$/, '\n');
};
/** `CHANGELOG.md`: the app notes, unreleased first, every release after. */
export const renderAppChangelog = ({ unreleased, releases }) => {
const sections = [];
sections.push(renderSection(unreleased ?? { version: null }, unreleased?.app, unreleased?.intro ?? []));
for (const release of releases) sections.push(renderSection(release, release.app, release.intro));
return `# Changelog\n\n${GENERATED_BANNER}\n\nAll notable changes to this project will be documented in this file.\n\n${sections.join('\n')}`;
};
/** `CHANGELOG.md` (legacy): every released version's app notes. */
export const renderAppChangelog = ({ releases }) =>
`# Changelog\n\n${LEGACY_BANNER}\n\n${releases.map((release) => renderSection(release, release.app, release.intro)).join('\n')}`;
/** `packages/vscode/CHANGELOG.md`: only releases that carry a VS Code section. */
export const renderVsCodeChangelog = ({ unreleased, releases }) => {
const sections = [];
sections.push(renderSection(unreleased ?? { version: null }, unreleased?.vscode, []));
for (const release of releases) {
if (!release.vscode) continue;
sections.push(renderSection(release, release.vscode, []));
}
return `${GENERATED_BANNER}\n\n${sections.join('\n')}`;
export const renderVsCodeChangelog = ({ releases }) =>
`${GENERATED_BANNER}\n\n${releases.filter((release) => release.vscode).map((release) => renderSection(release, release.vscode, [])).join('\n')}`;
/** GitHub Release body for one release: intro and groups, no version header. */
export const renderReleaseNotes = (release) => {
const parts = [];
if (release.intro.length > 0) parts.push(release.intro.join('\n'), '');
parts.push(renderGroups(release.app));
return `${parts.join('\n').trim()}\n`;
};
const groupsToJson = (groups) => {
@@ -208,12 +207,18 @@ export const renderIndex = ({ releases }) => `${JSON.stringify(releases.map((rel
vscode: groupsToJson(release.vscode),
})), null, 2)}\n`;
/** Every generated file, keyed by path relative to the repo root. */
export const renderOutputs = (loaded) => ({
'CHANGELOG.md': renderAppChangelog(loaded),
'packages/vscode/CHANGELOG.md': renderVsCodeChangelog(loaded),
'changelog/index.json': renderIndex(loaded),
});
/**
* Every generated file, keyed by path relative to the repo root. The legacy
* `CHANGELOG.md` is included only on request (the build passes whether the
* file still exists).
*/
export const renderOutputs = (loaded, { legacyAppChangelog = false } = {}) => {
const outputs = {};
if (legacyAppChangelog) outputs['CHANGELOG.md'] = renderAppChangelog(loaded);
outputs['packages/vscode/CHANGELOG.md'] = renderVsCodeChangelog(loaded);
outputs['changelog/index.json'] = renderIndex(loaded);
return outputs;
};
export const UNRELEASED_TEMPLATE = `---
title:
+22 -19
View File
@@ -4,9 +4,9 @@ import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { loadReleases, parseRelease, promoteUnreleased, renderAppChangelog, renderIndex, renderVsCodeChangelog } from './lib.mjs';
import { loadReleases, parseRelease, promoteUnreleased, renderAppChangelog, renderIndex, renderOutputs, renderReleaseNotes, renderVsCodeChangelog } from './lib.mjs';
const banner = '<!-- Generated from changelog/*.md by `bun run changelog:build`. Edit those files, not this one. -->';
const banner = '<!-- Generated from changelog/*.md by `oc-dev create-release`. Edit those files, not this one. -->';
const release = `---
version: 1.2.3
@@ -51,26 +51,16 @@ test('rejects shapes the generator cannot render', () => {
assert.throws(() => parseRelease('---\nversion: 1.2\ndate: 2026-01-31\n---\n', 'f.md'), /is not x\.y\.z/);
});
test('renders groups in canonical order with today\'s headers and skips versions without a VS Code section', () => {
test('renders released versions only, groups in canonical order, 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\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');
fs.writeFileSync(path.join(directory, 'unreleased.md'), '---\ntitle: Pending\n---\n\n## App\n\n### Fixes\n- Pending fix.\n\n## VS Code\n');
const loaded = loadReleases(directory);
assert.equal(renderAppChangelog(loaded), `# Changelog
${banner}
All notable changes to this project will be documented in this file.
## [Unreleased]
### Fixes
- Pending fix.
## [1.2.4] - 2026-02-01
const app = renderAppChangelog(loaded);
assert.match(app, /^# Changelog\n\n<!-- Legacy copy for app versions up to 1\.22\.1/);
assert.equal(app.slice(app.indexOf('## [1.2.4]')), `## [1.2.4] - 2026-02-01
### Improvements
@@ -88,16 +78,26 @@ A short intro.
- Chat: huge patches open without freezing the page (thanks to @someone).
`);
assert.equal(app.includes('Unreleased'), false);
assert.equal(renderVsCodeChangelog(loaded), `${banner}
## [Unreleased]
## [1.2.3] - 2026-01-31
### New
- Comments on code.
`);
assert.equal(renderReleaseNotes(loaded.releases[1]), `A short intro.
### New
- **Comments:** select text and comment on it.
### Fixes
- Chat: huge patches open without freezing the page (thanks to @someone).
`);
const index = JSON.parse(renderIndex(loaded));
@@ -106,6 +106,9 @@ A short intro.
assert.equal(index[1].title, 'Comments everywhere');
assert.deepEqual(index[1].vscode, { new: ['Comments on code.'], improvements: [], fixes: [], misc: [] });
assert.equal(index[0].vscode, null);
assert.deepEqual(Object.keys(renderOutputs(loaded)), ['packages/vscode/CHANGELOG.md', 'changelog/index.json']);
assert.deepEqual(Object.keys(renderOutputs(loaded, { legacyAppChangelog: true })), ['CHANGELOG.md', 'packages/vscode/CHANGELOG.md', 'changelog/index.json']);
});
test('loadReleases refuses a file whose name and version disagree, and a release without a title', () => {
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env node
// GitHub Release body and name for one version, from `changelog/<version>.md`.
//
// node scripts/changelog/release-notes.mjs 1.2.3 artifacts/release-notes.md
//
// Writes the body to the given file and prints the release title on stdout.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadReleases, renderReleaseNotes } from './lib.mjs';
const [version, outFile] = process.argv.slice(2);
if (!version || !outFile) {
console.error('usage: release-notes.mjs <version> <out-file>');
process.exit(2);
}
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
try {
const { releases } = loadReleases(path.join(repoRoot, 'changelog'));
const release = releases.find((entry) => entry.version === version);
if (!release) throw new Error(`changelog/${version}.md not found; run "oc-dev create-release" before tagging`);
fs.mkdirSync(path.dirname(outFile), { recursive: true });
fs.writeFileSync(outFile, renderReleaseNotes(release));
process.stdout.write(`${release.title}\n`);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
+4 -2
View File
@@ -175,7 +175,9 @@ function step(label, fn) {
}
// The release notes source plus the files generated from it; all go into the release commit.
const RELEASE_CHANGELOG_FILES = ['changelog', 'CHANGELOG.md', 'packages/vscode/CHANGELOG.md'];
// CHANGELOG.md is legacy (older installs read it for update notes); it is
// refreshed only while it exists.
const RELEASE_CHANGELOG_FILES = ['changelog', 'packages/vscode/CHANGELOG.md', ...(fs.existsSync('CHANGELOG.md') ? ['CHANGELOG.md'] : [])];
function printReleaseNextSteps(version) {
log.success(`Release v${version} prepared locally`);
@@ -602,7 +604,7 @@ async function createRelease(options) {
}
if (!/^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/.test(version)) throw new Error('Invalid version format. Use semver, e.g. 1.4.7 or 1.4.7-beta.1');
// Turns changelog/unreleased.md into changelog/<version>.md dated today and
// regenerates CHANGELOG.md; fails when nothing was written for the release.
// regenerates the outputs; fails when nothing was written for the release.
step('Promoting the changelog', () => run('node', ['scripts/changelog/build.mjs', '--release', version]));
step('Validating codebase', () => run('bun', ['run', 'release:prepare']));
step(`Bumping version to ${version}`, () => run('node', ['scripts/bump-version.mjs', version]));