#!/usr/bin/env node /** * Derive CHANGELOG.md's release counts from the checkout instead of typing them. * * WHY THIS EXISTS. The banner and the 版本节奏 table both state how many release notes * exist, and both were maintained by hand. On 2026-10-31 they disagreed: * * banner 218 ← the number of RELEASE_NOTES_*.md files * table 222 ← `find docs/releases -name '*.md' | wc -l` * * 222 was a *wrong measurement*, not a different fact: that `find` also counts README.md * and the three ACCEPTANCE_*.md appendices, which are not release notes. Two hand-written * numbers plus one wrong command produced a document that looked authoritative and failed * the guard test — and nothing regenerated it on the next release, so it would have * drifted again regardless of which number was right. * * So the numbers are now derived: this script rewrites the banner count and the two * counting rows from the filesystem and `git tag`. `--check` reports drift without * writing, and `test/docs-consistency-guard.test.js` asserts the same derivation, so a * hand edit fails before it can ship. * * The tag row counts the PUBLISHED namespace, not this checkout's refs: `git tag` minus * the tags named in `docs/releases/UNPUBLISHED-TAGS.txt` that this checkout happens to * carry, and minus the tag this release creates (`v`). Without the * register the row is 230 on the authoring host and 229 in every `fetch-depth: 0` CI * checkout (measured 2026-11-11), and it can only be right in one of them. Without the * packaged-tag exclusion the row moves by one at the moment the release tag is pushed: * 229 in master CI before the push, 230 in the Release checkout after it — the same * split, one commit later. Excluding the tag being cut makes the row one number in the * authoring checkout, in master CI, and at the tag. * * Run: node scripts/sync-changelog-counts.mjs [--check] */ import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); export const CHANGELOG_FILE = 'CHANGELOG.md'; export const RELEASES_DIR = 'docs/releases'; /** Tags that exist in the authoring checkout but were never pushed, one version per line. */ export const UNPUBLISHED_TAGS_FILE = 'docs/releases/UNPUBLISHED-TAGS.txt'; /** The v-prefix is a historical filename variant (v3.9.22), not a new convention. */ export const NOTE_FILE_RE = /^RELEASE_NOTES_v?(\d+\.\d+\.\d+)\.md$/; const BANNER_RE = /全部\s+\d+\s+份发布说明/; const NOTES_ROW_RE = /^\|\s*发布说明\s*\|.*$/m; const TAG_ROW_RE = /^\|\s*git tag\s*\|.*$/m; const semver = (version) => version.split('.').map(Number); const compareSemver = (a, b) => { const left = semver(a); const right = semver(b); for (let i = 0; i < 3; i += 1) if (left[i] !== right[i]) return left[i] - right[i]; return 0; }; /** Every release-notes version on disk, oldest first. ACCEPTANCE_*.md and README.md are not notes. */ export function releaseNoteVersions(root = ROOT) { const names = readdirSync(join(root, RELEASES_DIR)); // A notes file whose name does not carry a version is not silently skipped: the count // would drop by one and every reader would believe it. const malformed = names.filter((name) => name.startsWith('RELEASE_NOTES_') && !NOTE_FILE_RE.test(name)); if (malformed.length) { throw new Error(`release-notes files without a version in the name: ${malformed.join(', ')}`); } return names .map((name) => NOTE_FILE_RE.exec(name)?.[1]) .filter(Boolean) .sort(compareSemver); } /** Every tag in this checkout, or null when git cannot answer at all. */ export function listGitTags(root = ROOT) { try { const out = execFileSync('git', ['tag', '--list'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }); return out.split('\n').filter((line) => line.trim()); } catch { return null; } } /** * Versions whose tag exists in some checkouts and not others: a tag created locally and * never pushed. The list has to live in the tree because the tag namespace does not — * `git tag` answers "what did this clone fetch", not "what did this repository publish". * Measured 2026-11-11: the authoring host carried 230 tags, every `fetch-depth: 0` CI * checkout 229, and the delta was `v3.141.592659`, frozen on the unfixed `d5519da3` and * never pushed. A count of refs is therefore 230 on one host and 229 everywhere else, and * the row can only be right in one of them. * * One version per line; `#` starts a comment. An entry whose tag this checkout does not * have is not an error — that is exactly the CI checkout — it simply is not subtracted. */ export function unpublishedTagVersions(root = ROOT) { const path = join(root, UNPUBLISHED_TAGS_FILE); if (!existsSync(path)) return []; const versions = []; for (const [index, raw] of readFileSync(path, 'utf8').split('\n').entries()) { // `#` starts a comment; no `$` anchor, because a Windows checkout under // core.autocrlf=true delivers CRLF and `$` does not match before `\r`. const line = raw.replace(/#.*/, '').trim(); if (!line) continue; const match = /^v?(\d+\.\d+\.\d+)$/.exec(line); // A typo here would silently stop subtracting and the count would drift by one; refuse // the line instead, the same way releaseNoteVersions() refuses an unversioned filename. if (!match) throw new Error(`${UNPUBLISHED_TAGS_FILE}:${index + 1}: not a version: ${line}`); versions.push(match[1]); } return versions; } /** * The number the row claims: tags a PUBLISHED checkout has, excluding the tag this release * creates. Pure, so the invariance can be asserted without creating or deleting a tag: * * - a registered local-only tag is subtracted where this checkout has it and ignored * where it does not (the authoring host vs a `fetch-depth: 0` CI checkout); * - the packaged version's tag is subtracted whether or not this checkout has it yet * (master CI before the push vs the Release checkout at the tag). * * The packaged tag is excluded BY NAME, not by comparing versions. A `< package.json` * comparison is invariant too, but it silently drops tags that are not plain `x.y.z` — * this repository carries `v3.2.6-rc4`/`rc5`/`rc6` — and a count that quietly stops * counting tags is the defect this row exists to expose. * * Null — "cannot stand behind a count" — when git gave us no tags at all: a tarball * checkout has no history, and a shallow clone lists only the tags its fetched history * carries, so `fetch-depth: 1` reports zero for a repository that has hundreds. The caller * keeps the existing row instead of rewriting it to a zero. */ export function countPublishedTags(tags, unpublished = [], packaged = null) { if (!tags.length) return null; const localOnly = new Set(unpublished); const packagedTag = packaged === null ? null : `v${packaged}`; return tags.filter((tag) => tag !== packagedTag && !localOnly.has(tag.replace(/^v/, ''))).length; } export function changelogCounts(root = ROOT) { const versions = releaseNoteVersions(root); if (!versions.length) throw new Error(`no RELEASE_NOTES_*.md files under ${RELEASES_DIR}`); const tags = listGitTags(root); const { version: packaged } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); return { notes: versions.length, first: versions[0], last: versions.at(-1), tags: tags === null ? null : countPublishedTags(tags, unpublishedTagVersions(root), packaged), }; } const notesRow = (counts) => `| 发布说明 | **${counts.notes}** 份,` + `v${counts.first} → v${counts.last}` + `(生成:\`node scripts/sync-changelog-counts.mjs\`;口径 \`ls docs/releases/RELEASE_NOTES_*.md \\| wc -l\`) |`; const tagRow = (counts) => `| git tag | ${counts.tags} 个` + `(生成:\`node scripts/sync-changelog-counts.mjs\`;口径 \`git tag \\| wc -l\` 减去` + ` \`${UNPUBLISHED_TAGS_FILE}\` 里本 checkout 存在的未发布 tag,再减去 \`package.json\`` + ` 版本的 tag——本次发布自己的 tag,创建前后都不计数,所以三种 checkout 同值) |`; function replaceExactlyOnce(text, pattern, replacement, what) { const hits = [...text.matchAll(new RegExp(pattern.source, 'gm'))]; if (hits.length !== 1) throw new Error(`CHANGELOG.md must carry exactly one ${what}, found ${hits.length}`); return text.replace(pattern, replacement); } /** * Pure: the CHANGELOG with its counts set to `counts`. Throws rather than guess when a row * is missing or duplicated — a generator that silently skips a row is how the banner and * the table drifted apart in the first place. */ export function renderCounts(text, counts) { let out = replaceExactlyOnce(text, BANNER_RE, `全部 ${counts.notes} 份发布说明`, 'banner count (全部 N 份发布说明)'); out = replaceExactlyOnce(out, NOTES_ROW_RE, notesRow(counts), '发布说明 table row'); if (counts.tags !== null) out = replaceExactlyOnce(out, TAG_ROW_RE, tagRow(counts), 'git tag table row'); return out; } /** The rows the two counts live in, for a drift report. */ export function countClaims(text) { const banner = text.match(BANNER_RE)?.[0]?.match(/\d+/)?.[0] ?? null; const table = text.match(NOTES_ROW_RE)?.[0]?.match(/\*\*(\d+)\*\*/)?.[1] ?? null; const tag = text.match(TAG_ROW_RE)?.[0]?.match(/git tag\s*\|\s*(\d+)/)?.[1] ?? null; return { banner, table, tag }; } function parseArgs(argv) { const options = { check: false }; for (const arg of argv) { if (arg === '--check') { options.check = true; continue; } throw new Error(`unknown argument: ${arg}`); } return options; } function main(argv) { const { check } = parseArgs(argv); const path = join(ROOT, CHANGELOG_FILE); const before = readFileSync(path, 'utf8'); const counts = changelogCounts(ROOT); const after = renderCounts(before, counts); const truth = `notes=${counts.notes} tags=${counts.tags ?? 'unavailable'}`; if (before === after) { console.log(`CHANGELOG.md counts are current (${truth})`); return 0; } if (check) { const claims = countClaims(before); console.error(`CHANGELOG.md counts are stale (${truth}):` + ` banner=${claims.banner} table=${claims.table} tag=${claims.tag}`); console.error('run: node scripts/sync-changelog-counts.mjs'); return 1; } writeFileSync(path, after, 'utf8'); console.log(`CHANGELOG.md counts updated (${truth})`); return 0; } if (resolve(process.argv[1] || '') === resolve(fileURLToPath(import.meta.url))) { try { process.exitCode = main(process.argv.slice(2)); } catch (error) { console.error(error?.message || error); process.exitCode = 2; } }