Files
gods-eye-view/scripts/assemble-changelog.mjs
T
Said TurkseverandClaude Opus 5.5 4d42516ffe Make changelog assembly recoverable after a partial write
`--write` wrote CHANGELOG.md and then removed fragments one by one, so a
failure after the write left surviving fragments that a rerun spliced in
a second time.

The write is now staged. The assembled file goes into
changelog.d/.assembling/, the fragments are renamed in beside it, and
renaming the assembled file over CHANGELOG.md is the single commit
point. A run that fails rolls itself back when it can; otherwise the next
`npm run changelog` reads the staging directory and either restores the
fragments (assembled file still staged) or deletes the leftovers (batch
landed). `--check` reports a staged batch instead of reporting past it.

A failure-injection test makes every filesystem call in a `--write` fail
in turn, both as a one-off error and as a crash with no in-process
rollback. It asserts the retry produces exactly the uninterrupted
CHANGELOG.md, with each entry once and an empty directory. The test
fails against the previous write-then-delete and against delete-first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 11:01:25 +01:00

205 lines
7.3 KiB
JavaScript

import * as fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
/**
* Release-time assembly of changelog fragments.
*
* A pull request adds `changelog.d/<pull request>-<slug>.md` instead of editing
* `CHANGELOG.md`. Distinct filenames never conflict, so two pull requests in
* flight no longer write into the same few lines at the top of one file. A
* release splices the fragments in and empties the directory.
*
* Fragment text is copied through unchanged apart from trailing whitespace:
* `src/annotations/drawTool.test.mjs` reads `CHANGELOG.md` as prose and slices a
* fixed window after a heading, so reflowing an entry here could move text
* across that window.
*
* A write is staged so that a failure at any point leaves a rerun unable to
* duplicate or lose an entry. The assembled file is written into
* `changelog.d/.assembling/` first, the fragments are renamed in beside it, and
* renaming the assembled file over `CHANGELOG.md` is the single commit point.
* While the assembled file is still staged the batch has not landed, so
* recovery moves the fragments back; once it is gone the batch has landed, and
* the staged fragments are leftovers to delete. Either way they are out of the
* input directory, so they are never assembled twice.
*/
export const FRAGMENT_DIR = 'changelog.d';
export const STAGING_DIR = '.assembling';
const STAGED_CHANGELOG = 'CHANGELOG.md';
const FRAGMENT_NAME = /^(\d+)-[a-z0-9]+(?:-[a-z0-9]+)*\.md$/;
const HEADING = '# Changelog\n';
/** Fragment file names a release may consume, highest pull request first. */
export function orderFragmentNames(names) {
const claimed = new Map();
for (const name of names) {
if (name === 'README.md') continue;
const match = FRAGMENT_NAME.exec(name);
if (!match) {
throw new Error(
`Unexpected file in ${FRAGMENT_DIR}/: ${name}` +
' — expected <pull request>-<slug>.md with a lowercase slug',
);
}
const number = Number(match[1]);
const prior = claimed.get(number);
if (prior) {
throw new Error(
`Two fragments claim pull request ${number}: ${prior} and ${name}`,
);
}
claimed.set(number, name);
}
return [...claimed.entries()]
.sort(([a], [b]) => b - a)
.map(([, name]) => name);
}
/** Splice fragment bodies above the existing entries, newest batch on top. */
export function assembleChangelog(changelog, fragments) {
if (!fragments.length) return changelog;
if (!changelog.startsWith(HEADING)) {
throw new Error('CHANGELOG.md must start with "# Changelog"');
}
const entries = changelog.slice(HEADING.length).replace(/^\n+/, '');
const batch = fragments.map((text) => text.replace(/\s+$/, '')).join('\n\n');
return `${HEADING}\n${batch}\n\n${entries}`;
}
/**
* Finish or roll back a batch an earlier `--write` left in the staging
* directory. A no-op when there is none.
*
* @returns {Promise<'none'|'rolled-back'|'cleaned-up'>}
*/
export async function recoverInterruptedBatch(root, io = fs) {
const directory = path.join(root, FRAGMENT_DIR);
const staging = path.join(directory, STAGING_DIR);
let staged;
try {
staged = await io.readdir(staging);
} catch (error) {
if (error.code === 'ENOENT') return 'none';
throw error;
}
if (!staged.includes(STAGED_CHANGELOG)) {
// The assembled file was renamed over CHANGELOG.md: the batch landed.
await io.rm(staging, { recursive: true, force: true });
return 'cleaned-up';
}
// The batch never reached CHANGELOG.md, so every fragment goes back.
const pending = new Set(await io.readdir(directory));
const fragments = staged.filter((name) => name !== STAGED_CHANGELOG);
const clash = fragments.filter((name) => pending.has(name));
if (clash.length) {
throw new Error(
`Cannot roll back ${FRAGMENT_DIR}/${STAGING_DIR}/: ${clash.join(', ')}` +
` also exist in ${FRAGMENT_DIR}/ — resolve by hand`,
);
}
for (const name of fragments) {
await io.rename(path.join(staging, name), path.join(directory, name));
}
await io.rm(staging, { recursive: true, force: true });
return 'rolled-back';
}
/**
* Report or apply the pending fragments in `<root>/changelog.d`.
*
* The batch is validated in full before anything is written, so a malformed
* filename or a duplicate pull request number leaves the directory untouched.
* `--write` first recovers any batch an interrupted run left staged.
*
* @param {string} root repository root
* @param {'--check'|'--write'} mode `--check` reports and changes nothing
* @param {typeof fs} [io] filesystem calls, replaceable for failure injection
* @returns {Promise<{ consumed: string[], changelog: string }>}
*/
export async function assembleFragments(root, mode, io = fs) {
if (!['--check', '--write'].includes(mode)) {
throw new Error(
'Usage: node scripts/assemble-changelog.mjs --check|--write',
);
}
const directory = path.join(root, FRAGMENT_DIR);
const staging = path.join(directory, STAGING_DIR);
const changelogFile = path.join(root, 'CHANGELOG.md');
if (mode === '--write') {
await recoverInterruptedBatch(root, io);
}
const names = await io.readdir(directory);
if (names.includes(STAGING_DIR)) {
throw new Error(
`${FRAGMENT_DIR}/${STAGING_DIR}/ holds a batch an interrupted release` +
' left behind — `npm run changelog` finishes or rolls it back',
);
}
const consumed = orderFragmentNames(names);
const bodies = [];
for (const name of consumed) {
bodies.push(await io.readFile(path.join(directory, name), 'utf8'));
}
const changelog = assembleChangelog(
await io.readFile(changelogFile, 'utf8'),
bodies,
);
if (mode === '--write' && consumed.length) {
try {
await io.mkdir(staging);
await io.writeFile(
path.join(staging, STAGED_CHANGELOG),
changelog,
'utf8',
);
for (const name of consumed) {
await io.rename(path.join(directory, name), path.join(staging, name));
}
await io.rename(path.join(staging, STAGED_CHANGELOG), changelogFile);
await io.rm(staging, { recursive: true, force: true });
} catch (error) {
try {
await recoverInterruptedBatch(root, io);
} catch {
throw new Error(
`${error.message} — the batch is staged in ${FRAGMENT_DIR}/${STAGING_DIR}/,` +
' and the next `npm run changelog` finishes or rolls it back',
{ cause: error },
);
}
throw error;
}
}
return { consumed, changelog };
}
const invoked = process.argv[1]
? pathToFileURL(path.resolve(process.argv[1])).href
: '';
if (import.meta.url === invoked) {
try {
if (process.argv.length !== 3) {
throw new Error(
'Usage: node scripts/assemble-changelog.mjs --check|--write',
);
}
const mode = process.argv[2];
const { consumed } = await assembleFragments(
fileURLToPath(new URL('../', import.meta.url)),
mode,
);
if (!consumed.length) {
console.log(`No fragments in ${FRAGMENT_DIR}/.`);
} else {
console.log(
`${mode === '--write' ? 'Assembled' : 'Would assemble'} ${consumed.length} fragment(s), in order:` +
`\n ${consumed.join('\n ')}`,
);
}
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}