diff --git a/scripts/sync-release-index-currency.mjs b/scripts/sync-release-index-currency.mjs index 28f5ee09dd5..d79ce19a99d 100644 --- a/scripts/sync-release-index-currency.mjs +++ b/scripts/sync-release-index-currency.mjs @@ -108,15 +108,46 @@ // • An entry with no trailing parenthetical at all. There is no field to stamp, and // inventing one means choosing between the two wordings above -- the same judgement. // • An entry whose parenthetical is prose. Same reason. -// • A `current series:` field that already names the newest GA, whatever its date. -// The gate has no finding there, and the date belongs to the version commit that -// moved the field, which a run that moves nothing is not -- every prerelease cut -// is such a run (see `releaseDate`). +// • A `current series:` field that already names the newest GA, whatever its date -- +// on every run EXCEPT that GA's own version run (next section). The gate has no +// finding there, and the date belongs to the version commit of the version the +// field names, which a run that moves nothing is not -- every prerelease cut is +// such a run (see `releaseDate`). +// +// ## The one same-version field it DOES re-date: the newest GA's own version run (#25356) +// +// Measured on 18.0.0. `8fbbd5082` (#24584) wrote the v18 field by hand ahead of the +// cut, "(current series: 18.0.0, released 2026-10-19)". When the version pass that +// wrote `## 18.0.0` ran, the field already named the newest GA, so the refusal above +// left it alone, date included. That pass's commit (`31abb7184c`, #24524) is dated +// 2026-10-20, npm published 18.0.0 on 2026-10-20, and the index shipped a day early +// until #25354 corrected it by hand. +// +// The date this run holds IS true on exactly one run per GA: the one whose +// `changeset version` wrote that GA's CHANGELOG heading. That run is detectable +// with no clock and no network: the heading of the major's newest GA is in the +// working tree's `packages/spec/CHANGELOG.md` and NOT in the one committed at `HEAD` +// (`committedChangelogText`). On that run, and only on it, a same-version field is +// re-dated. Every other run keeps the refusal, so the 17.7.0 re-dating it prevents +// (see `rewriteStatusField`) cannot come back: +// +// • a prerelease cut writes only a prerelease heading, which is never a GA; +// • a run with nothing pending writes no heading at all; +// • a run on a tree whose version commit has already landed finds the heading at +// `HEAD`. +// +// Judged PER MAJOR, never as "the newest GA of the repository moved": on the 18.1.0 +// run a v17 field naming 17.7.0 still names 17.x's newest GA, and re-dating it to +// 18.1.0's day would be the 17.7.0 defect again. +// +// Rejected: comparing the date with npm's `time` for the version. That needs the +// network at version time, and the `version-pr` lane must not depend on it. // // An over-eager rewriter is not a lesser failure than an inert one: this file writes // into curated, reader-facing prose, so `--self-test` asserts a CURRENT index is left // BYTE-IDENTICAL with no write at all, and that each refused shape survives untouched. +import { execFileSync, spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -133,6 +164,7 @@ import { indexStatusField, newestGaOfMajor, } from './check-release-section-coverage.mjs'; +import { gitFreeEnv } from './git-env.mjs'; import { isEntrypoint } from './invoked-as.mjs'; /** The repo this script lives in -- resolved from the script, so cwd cannot lie. */ @@ -180,11 +212,13 @@ export const STATUS_SHAPE = * version`. UTC, not local: a runner in any timezone must stamp the day the commit is * dated, and `git` dates the commit in UTC in CI. * - * ⛔ So the date is TRUE only for the version this run moves the field TO. A run that - * leaves the version where it is -- every prerelease cut, `18.0.0-next.N` and - * `X.Y.Z-rc.N` alike, because a prerelease heading never becomes the newest GA -- is - * not that version's version commit, and today is not its release date. That is why - * `rewriteStatusField` refuses a same-version field outright instead of re-dating it. + * ⛔ So the date is TRUE only for the version whose version commit this run is: the + * version it moves the field TO, or the newest GA whose CHANGELOG heading this run's + * `changeset version` wrote (`gaVersionRun`). A run that is neither -- every + * prerelease cut, `18.0.0-next.N` and `X.Y.Z-rc.N` alike, because a prerelease + * heading never becomes the newest GA -- is not that version's version commit, and + * today is not its release date. That is why `rewriteStatusField` refuses a + * same-version field on every such run instead of re-dating it. * * A clock this cannot read is a REFUSAL, never a silently wrong date written into * published prose. @@ -210,29 +244,101 @@ export function releaseDate(now = new Date()) { * verdict, which is where a refusal becomes loud. See "What it deliberately does NOT * rewrite". * - * A field that already names `newestVersion` is left alone, date included. The gate - * judges the VERSION only (`indexCurrencyFindings` has nothing to say about such a - * field), and the date this run holds is the release date of the version it moves the - * field to -- see `releaseDate`. Re-dating a field whose version did not move would - * write the day of an unrelated version pass into published prose: measured on the - * first `18.0.0-next.0` cut, `(current series: 17.7.0, released 2026-10-06)` became - * `released 2026-10-07`, while 17.7.0's version commit `4e4e881427` is dated - * 2026-10-06. In pre mode the newest GA never moves, so every prerelease cut did it. + * A field that already names `newestVersion` is left alone, date included, unless + * `gaVersionRun` says this run IS `newestVersion`'s version commit. The gate judges the + * VERSION only (`indexCurrencyFindings` has nothing to say about such a field), and the + * date this run holds is the release date of the version whose version commit it is -- + * see `releaseDate`. Re-dating a field on any other run would write the day of an + * unrelated version pass into published prose: measured on the first `18.0.0-next.0` + * cut, `(current series: 17.7.0, released 2026-10-06)` became `released 2026-10-07`, + * while 17.7.0's version commit `4e4e881427` is dated 2026-10-06. In pre mode the + * newest GA never moves, so every prerelease cut did it. + * + * On the GA's own version run the same-version field is re-dated, because there the + * date is true and a field written by hand ahead of the cut is not -- the 18.0.0 case, + * see "The one same-version field it DOES re-date". A re-date that changes nothing (the + * field already carries this run's date) is null too, so a current index stays unwritten. * * @param {string} field the inner text of the entry's trailing parenthetical * @param {string} newestVersion * @param {string} date `YYYY-MM-DD` + * @param {{ gaVersionRun?: boolean }} [options] true only on the run whose `changeset + * version` wrote `newestVersion`'s CHANGELOG heading (`gaVersionRunMajors`) * @returns {string | null} */ -export function rewriteStatusField(field, newestVersion, date) { +export function rewriteStatusField(field, newestVersion, date, { gaVersionRun = false } = {}) { const m = STATUS_SHAPE.exec(field); if (m === null) return null; - if (m[2] === newestVersion) return null; - return `${m[1]}${newestVersion}${m[3]}${date}`; + if (m[2] === newestVersion && !gaVersionRun) return null; + const rewritten = `${m[1]}${newestVersion}${m[3]}${date}`; + return rewritten === field ? null : rewritten; } /** - * @typedef {{ major: number, from: string, to: string }} Rewrite + * The spec CHANGELOG as committed at `HEAD`: the tree as it stood BEFORE this run's + * `changeset version`, read from the object database. No clock and no network. + * + * On the `version-pr` lane `HEAD` is the main commit being rendered: release.yml's + * post-version step and changesets/action both run `pnpm run version` on a tree reset + * to it, and the step diffs its output against `HEAD` the same way. cut-rc.yml runs + * it on its snapshot commit. A shallow checkout still holds `HEAD`'s own tree. + * + * `gitFreeEnv()`: the repository is the one `root` names, never one an inherited + * GIT_DIR names (`GIT_ENV_ISOLATION_RULE`), so the self-test can hand this a throwaway + * repository from inside a hook. + * + * A `HEAD` this cannot read is a REFUSAL. Treating it as "no GA at HEAD" would re-date + * every same-version field on every run, the 17.7.0 defect; treating it as "nothing + * moved" would leave a hand-written date in place, the 18.0.0 one. + * + * @param {string} root + * @returns {string} + */ +export function committedChangelogText(root) { + const r = spawnSync('git', ['show', `HEAD:${SPEC_CHANGELOG}`], { + cwd: root, + env: gitFreeEnv(), + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }); + if (r.error || r.status !== 0) { + const why = r.error ? String(r.error.message ?? r.error) : `exit ${r.status}: ${String(r.stderr).trim()}`; + throw new Error( + `sync-release-index-currency: could not read ${SPEC_CHANGELOG} as committed at HEAD in ${root} ` + + `(git show ${why}). Without it this run cannot tell whether it is the version commit of a ` + + 'newest GA, which decides whether a same-version status field may be re-dated. Refusing ' + + 'rather than guessing either way.', + ); + } + return r.stdout; +} + +/** + * The majors whose newest GA this run's `changeset version` introduced: the heading of + * that GA is in `changelogText` (the working tree) and not in `headChangelogText` + * (committed at `HEAD`). This run is that GA's version run, and its date is that GA's. + * + * "Introduced" rather than "the newest GA differs": a working tree that LOST a heading + * `HEAD` carries also differs, and it is not anyone's version commit. Per major, never + * repository-wide -- see "The one same-version field it DOES re-date". + * + * @param {{ changelogText: string, headChangelogText: string, majors: number[] }} input + * @returns {Set} + */ +export function gaVersionRunMajors({ changelogText, headChangelogText, majors }) { + const versions = gaVersions(changelogText); + const atHead = new Set(gaVersions(headChangelogText).map((v) => v.join('.'))); + const out = new Set(); + for (const major of majors) { + const newest = newestGaOfMajor(versions, major); + if (newest !== null && !atHead.has(newest)) out.add(major); + } + return out; +} + +/** + * @typedef {{ major: number, from: string, to: string, redated: boolean }} Rewrite + * `redated`: the version already matched, and this is the GA's own version run. */ /** @@ -248,12 +354,13 @@ export function rewriteStatusField(field, newestVersion, date) { * @param {number} major * @param {string} newestVersion * @param {string} date + * @param {{ gaVersionRun?: boolean }} [options] see `rewriteStatusField` * @returns {{ line: string, rewrite: Rewrite | null }} */ -export function rewriteEntryLine(line, major, newestVersion, date) { +export function rewriteEntryLine(line, major, newestVersion, date, { gaVersionRun = false } = {}) { const field = indexStatusField(line); if (field === null) return { line, rewrite: null }; - const rewritten = rewriteStatusField(field, newestVersion, date); + const rewritten = rewriteStatusField(field, newestVersion, date, { gaVersionRun }); if (rewritten === null) return { line, rewrite: null }; const at = line.lastIndexOf(`(${field})`); @@ -265,7 +372,8 @@ export function rewriteEntryLine(line, major, newestVersion, date) { ); } const next = `${line.slice(0, at)}(${rewritten})${line.slice(at + field.length + 2)}`; - return { line: next, rewrite: { major, from: field, to: rewritten } }; + const redated = STATUS_SHAPE.exec(field)?.[2] === newestVersion; + return { line: next, rewrite: { major, from: field, to: rewritten, redated } }; } /** @@ -275,13 +383,26 @@ export function rewriteEntryLine(line, major, newestVersion, date) { * filtered by the gate's `inScope()` -- so this rewriter can never consider a major the * gate does not judge, nor skip one it does. * - * @param {{ indexText: string, changelogText: string, date: string }} input - * @returns {{ text: string, rewrites: Rewrite[], majors: number[] }} + * `headChangelogText` is REQUIRED: it is the CHANGELOG committed at `HEAD`, and it is + * what tells a GA's own version run from every other run (`gaVersionRunMajors`). On a + * run with nothing to version it equals `changelogText`. A caller that omitted it would + * silently get the 18.0.0 behaviour back, so its absence is a refusal. + * + * @param {{ indexText: string, changelogText: string, headChangelogText: string, date: string }} input + * @returns {{ text: string, rewrites: Rewrite[], majors: number[], gaVersionRun: number[] }} */ -export function rewriteIndexText({ indexText, changelogText, date }) { +export function rewriteIndexText({ indexText, changelogText, headChangelogText, date }) { + if (typeof headChangelogText !== 'string') { + throw new Error( + 'sync-release-index-currency: rewriteIndexText needs headChangelogText, the CHANGELOG as ' + + 'committed at HEAD. Without it a GA\'s own version run cannot be told from any other run, ' + + 'so a same-version status field could be neither re-dated safely nor left alone safely.', + ); + } const versions = gaVersions(changelogText); const minors = gaMinors(versions).filter(([maj]) => inScope(maj)); const majors = [...new Set(minors.map(([maj]) => maj))].sort((a, b) => a - b); + const versionRun = gaVersionRunMajors({ changelogText, headChangelogText, majors }); const normalised = indexText.replace(/\r\n?/g, '\n'); if (normalised !== indexText) { @@ -304,7 +425,9 @@ export function rewriteIndexText({ indexText, changelogText, date }) { // A major with no entry line is check:release-page-status's verdict, not this // file's -- the gate says nothing about it either. if (line === null) continue; - const { line: next, rewrite } = rewriteEntryLine(line, major, newest, date); + const { line: next, rewrite } = rewriteEntryLine(line, major, newest, date, { + gaVersionRun: versionRun.has(major), + }); if (rewrite === null) continue; const at = lines.indexOf(line); if (at < 0) { @@ -318,7 +441,7 @@ export function rewriteIndexText({ indexText, changelogText, date }) { rewrites.push(rewrite); } - return { text, rewrites, majors }; + return { text, rewrites, majors, gaVersionRun: [...versionRun].sort((a, b) => a - b) }; } /** @@ -352,8 +475,11 @@ export function currencyFindings({ indexText, changelogText }) { * bytes. Leaving mtime untouched keeps a no-op release from showing a "modified" file a * reviewer then has to diff to discover is empty. * + * The CHANGELOG committed at `HEAD` is read here, by `committedChangelogText`, so the + * one path `main()` takes is the path the self-test drives on a real repository. + * * @param {{ root: string, date: string }} options - * @returns {{ path: string, rewrites: Rewrite[], wrote: boolean, findings: string[], majors: number[] }} + * @returns {{ path: string, rewrites: Rewrite[], wrote: boolean, findings: string[], majors: number[], gaVersionRun: number[] }} */ export function syncIndex({ root, date }) { const indexFull = join(root, INDEX_PATH); @@ -379,12 +505,18 @@ export function syncIndex({ root, date }) { const changelogText = readFileSync(changelogFull, 'utf8'); const indexText = readFileSync(indexFull, 'utf8'); + const headChangelogText = committedChangelogText(root); - const { text, rewrites, majors } = rewriteIndexText({ indexText, changelogText, date }); + const { text, rewrites, majors, gaVersionRun } = rewriteIndexText({ + indexText, changelogText, headChangelogText, date, + }); const wrote = rewrites.length > 0; if (wrote) writeFileSync(indexFull, text); - return { path: INDEX_PATH, rewrites, wrote, majors, findings: currencyFindings({ indexText: text, changelogText }) }; + return { + path: INDEX_PATH, rewrites, wrote, majors, gaVersionRun, + findings: currencyFindings({ indexText: text, changelogText }), + }; } // --------------------------------------------------------------------------- @@ -402,7 +534,11 @@ function main() { } else { console.log(` ${INDEX_PATH}`); for (const rewrite of rewrites) { - console.log(` v${rewrite.major}: (${rewrite.from}) → (${rewrite.to})`); + const why = rewrite.redated + ? ` — re-dated: this run's version pass wrote the newest v${rewrite.major} GA heading, ` + + 'so this is its version commit' + : ''; + console.log(` v${rewrite.major}: (${rewrite.from}) → (${rewrite.to})${why}`); } console.log( `✓ sync-release-index-currency: ${rewrites.length} status field(s) stamped from ` @@ -466,11 +602,12 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'Control F: the surface and the scope are the gate\'s, not copies': 4, 'Control G: the date refuses an unusable clock': 3, 'Control H: a prerelease cut never re-dates the newest GA': 7, + 'Control I: the newest GA\'s own version run re-dates a pre-written field': 16, }); // DELETING an entry silences that battery's floor exactly as effectively as zeroing it, // so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 8; +const SELF_TEST_BATTERY_FLOOR = 9; // The key an assertion is filed under when no battery is open. It is not a declared // battery, so it reds by the same set difference rather than silently inflating @@ -518,10 +655,45 @@ const OUT_OF_SCOPE_V15 = '- [v15.0.0](/docs/releases/v15) — Explain record access layer by layer (current series: 15.0.0, ' + 'released 2026-07-01).'; +/** The same CHANGELOG as committed BEFORE the 17.3.0 version run: no `## 17.3.0` yet. */ +const CHANGELOG_BEFORE_17_3_FIXTURE = CHANGELOG_FIXTURE.replace('\n## 17.3.0\n\n', '\n'); + +/** 17.3.0's field written by hand ahead of the cut, dated the day it was written. */ +const PREWRITTEN_V17 = CURRENT_V17.replace('released 2026-09-04', 'released 2026-09-01'); + const STAMP_DATE = '2026-09-04'; const indexOf = (...entries) => ['## Versions', '', ...entries, ''].join('\n'); +/** + * A throwaway repository shaped like the version lane's checkout: `index` and + * `committed` are the release index and spec CHANGELOG at `HEAD` (`committed: null` + * commits no CHANGELOG), and `working` is the CHANGELOG this run's `changeset version` + * left in the tree. Every git child gets `gitFreeEnv()` (`GIT_ENV_ISOLATION_RULE`). + * + * @param {{ index: string, committed: string | null, working: string }} input + */ +function checkoutTree({ index, committed, working }) { + const dir = mkdtempSync(join(tmpdir(), 'sync-release-index-currency-')); + const git = (...args) => execFileSync('git', args, { + cwd: dir, env: gitFreeEnv(), stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', + }); + const write = (rel, text) => { + mkdirSync(dirname(join(dir, rel)), { recursive: true }); + writeFileSync(join(dir, rel), text); + }; + git('init', '-q', '-b', 'main'); + git('config', 'user.email', 'selftest@example.invalid'); + git('config', 'user.name', 'self test'); + git('config', 'commit.gpgsign', 'false'); + write(INDEX_PATH, index); + if (committed !== null) write(SPEC_CHANGELOG, committed); + git('add', '-A'); + git('commit', '-q', '--no-gpg-sign', '-m', 'HEAD'); + write(SPEC_CHANGELOG, working); + return { dir, git }; +} + export function selfTest() { const seen = new Map(); let openBattery = null; @@ -537,8 +709,10 @@ export function selfTest() { if (!cond) failures.push(label); }; + // Controls A-E run on a tree with nothing to version: the CHANGELOG at HEAD is the + // CHANGELOG in the tree, so no major is on its GA's own version run. const run = (indexText, date = STAMP_DATE) => - rewriteIndexText({ indexText, changelogText: CHANGELOG_FIXTURE, date }); + rewriteIndexText({ indexText, changelogText: CHANGELOG_FIXTURE, headChangelogText: CHANGELOG_FIXTURE, date }); // ── Control A ───────────────────────────────────────────────────────────── battery('Control A: a STALE entry is stamped — version AND date'); @@ -678,7 +852,12 @@ export function selfTest() { const rcCut = withHeading('## 17.4.0-rc.0'); const current = indexOf(CURRENT_V17, CURRENT_V16); - const afterNext = rewriteIndexText({ indexText: current, changelogText: nextCut, date: CUT_DATE }); + // HEAD carries the fixture as committed; the cut's `changeset version` added only + // the prerelease heading. + const cut = (indexText, changelogText) => rewriteIndexText({ + indexText, changelogText, headChangelogText: CHANGELOG_FIXTURE, date: CUT_DATE, + }); + const afterNext = cut(current, nextCut); expect('H — a `next` prerelease cut on a later day rewrites nothing', afterNext.rewrites.length === 0 && afterNext.text === current); expect('H — the newest GA keeps its own release date, not the cut\'s', @@ -689,27 +868,21 @@ export function selfTest() { expect('H — and refusing the re-date leaves the gate nothing to say', currencyFindings({ indexText: afterNext.text, changelogText: nextCut }).length === 0); - const afterRc = rewriteIndexText({ indexText: current, changelogText: rcCut, date: CUT_DATE }); + const afterRc = cut(current, rcCut); expect('H — an `rc` prerelease cut on a later day rewrites nothing either', afterRc.rewrites.length === 0 && afterRc.text === current); // Positive control on the same later day: the date still travels WITH a version that // moves, so the refusal above is about the version standing still, not an inert date. - const staleAfterNext = rewriteIndexText({ - indexText: indexOf(STALE_V17, CURRENT_V16), changelogText: nextCut, date: CUT_DATE, - }); + const staleAfterNext = cut(indexOf(STALE_V17, CURRENT_V16), nextCut); expect('H — control: a STALE entry on the same cut is still stamped, version AND that day', staleAfterNext.rewrites.length === 1 && staleAfterNext.text.includes(`current series: 17.3.0, released ${CUT_DATE}`)); // End to end through syncIndex, the function main() calls: on a checkout-shaped temp - // tree the index file is not written at all, not rewritten to identical bytes. - const tmp = mkdtempSync(join(tmpdir(), 'sync-release-index-currency-')); + // repository the index file is not written at all, not rewritten to identical bytes. + const { dir: tmp } = checkoutTree({ index: current, committed: CHANGELOG_FIXTURE, working: nextCut }); try { - mkdirSync(dirname(join(tmp, INDEX_PATH)), { recursive: true }); - mkdirSync(dirname(join(tmp, SPEC_CHANGELOG)), { recursive: true }); - writeFileSync(join(tmp, INDEX_PATH), current); - writeFileSync(join(tmp, SPEC_CHANGELOG), nextCut); const result = syncIndex({ root: tmp, date: CUT_DATE }); expect('H — syncIndex on a next-cut tree reports no write and leaves the index byte-identical', result.wrote === false && result.rewrites.length === 0 @@ -719,6 +892,107 @@ export function selfTest() { } } + // ── Control I ───────────────────────────────────────────────────────────── + // The measured defect (#25356): 18.0.0's field was written by hand ahead of the cut, + // and the version run that wrote `## 18.0.0` left the hand-written date in place. On + // that run, and on no other, a same-version field takes the run's date. + battery('Control I: the newest GA\'s own version run re-dates a pre-written field'); + { + const prewritten = indexOf(PREWRITTEN_V17, CURRENT_V16); + const gaRun = (indexText, changelogText, headChangelogText, date = STAMP_DATE) => + rewriteIndexText({ indexText, changelogText, headChangelogText, date }); + + const onGaRun = gaRun(prewritten, CHANGELOG_FIXTURE, CHANGELOG_BEFORE_17_3_FIXTURE); + expect('I — on the run whose version pass wrote `## 17.3.0`, a pre-written 17.3.0 field is ' + + 're-dated: there the date is true and the hand-written one is not', + onGaRun.rewrites.length === 1 && onGaRun.rewrites[0].major === 17 && onGaRun.rewrites[0].redated === true); + expect('I — the re-dated line is EXACTLY the current entry, version kept, date moved', + onGaRun.text.split('\n').includes(CURRENT_V17) && !onGaRun.text.includes('released 2026-09-01')); + expect('I — only v17 is on its GA\'s version run; 16.1.0\'s heading is already at HEAD', + onGaRun.gaVersionRun.length === 1 && onGaRun.gaVersionRun[0] === 17); + expect('I — and the gate has nothing to say after it', + currencyFindings({ indexText: onGaRun.text, changelogText: CHANGELOG_FIXTURE }).length === 0); + + const alreadyDated = indexOf(CURRENT_V17, CURRENT_V16); + const sameDay = gaRun(alreadyDated, CHANGELOG_FIXTURE, CHANGELOG_BEFORE_17_3_FIXTURE); + expect('I — on the GA run a field already carrying the run\'s date is no rewrite and no write', + sameDay.rewrites.length === 0 && sameDay.text === alreadyDated); + + const rcAfter17 = CHANGELOG_FIXTURE.replace('## 17.3.0-rc.1', '## 17.4.0-rc.0\n\n## 17.3.0-rc.1'); + const onRc = gaRun(prewritten, rcAfter17, CHANGELOG_FIXTURE); + expect('I — a PRERELEASE run leaves the pre-written field alone: its version pass wrote only ' + + 'a prerelease heading, so it is no GA\'s version commit', + onRc.rewrites.length === 0 && onRc.text === prewritten && onRc.gaVersionRun.length === 0); + + const onNothing = gaRun(prewritten, CHANGELOG_FIXTURE, CHANGELOG_FIXTURE); + expect('I — a NON-VERSION run (CHANGELOG at HEAD = CHANGELOG in the tree) leaves it alone too', + onNothing.rewrites.length === 0 && onNothing.text === prewritten); + + const lostHeading = gaRun(prewritten, CHANGELOG_FIXTURE, + CHANGELOG_FIXTURE.replace('## 17.3.0-rc.1', '## 17.4.0')); + expect('I — a tree that LOST a GA heading HEAD carries is no version run: 17.3.0\'s heading is ' + + 'at HEAD, so nothing is re-dated (introduced, not merely different)', + lostHeading.rewrites.length === 0 && lostHeading.gaVersionRun.length === 0); + + expect('I — at the field level the re-date keeps the version and takes the run\'s date', + rewriteStatusField('current series: 17.3.0, released 2026-09-01', '17.3.0', STAMP_DATE, { gaVersionRun: true }) + === `current series: 17.3.0, released ${STAMP_DATE}`); + expect('I — the GA run opens no refused shape: "final release:" and trailing matter stay refused', + rewriteStatusField('final release: 16.1.0', '16.1.0', STAMP_DATE, { gaVersionRun: true }) === null + && rewriteStatusField('current series: 17.3.0, released 2026-09-01; see below', '17.3.0', STAMP_DATE, + { gaVersionRun: true }) === null); + + // Per major, on the measured shape: the 18.0.0 run must re-date v18 and leave v17, + // whose field still names 17.x's newest GA, exactly as it is. + const V18_PREWRITTEN = '- [v18](/docs/releases/v18) — The line that carries ADR-0131 ' + + '(current series: 18.0.0, released 2026-10-19).'; + const ga18 = CHANGELOG_FIXTURE.replace('## 17.3.0-rc.1', '## 18.0.0\n\n## 18.0.0-next.5\n\n## 17.3.0-rc.1'); + const before18 = CHANGELOG_FIXTURE.replace('## 17.3.0-rc.1', '## 18.0.0-next.5\n\n## 17.3.0-rc.1'); + const on18 = gaRun(indexOf(V18_PREWRITTEN, CURRENT_V17, CURRENT_V16), ga18, before18, '2026-10-20'); + expect('I — the 18.0.0 shape: v18\'s hand-written 2026-10-19 becomes the run\'s 2026-10-20', + on18.rewrites.length === 1 && on18.rewrites[0].major === 18 + && on18.text.includes('(current series: 18.0.0, released 2026-10-20)')); + expect('I — and v17 is NOT re-dated on 18.0.0\'s run: the version run is judged per major, never ' + + '"the repository\'s newest GA moved"', on18.text.includes(CURRENT_V17) && on18.gaVersionRun.join() === '18'); + + let threwWithoutHead = false; + try { + rewriteIndexText({ indexText: prewritten, changelogText: CHANGELOG_FIXTURE, date: STAMP_DATE }); + } catch { threwWithoutHead = true; } + expect('I — rewriteIndexText REFUSES without the CHANGELOG at HEAD rather than defaulting to ' + + 'either answer', threwWithoutHead); + + // End to end through syncIndex on a real repository: `committedChangelogText` reads + // HEAD with git, the path main() takes. + const ga = checkoutTree({ index: prewritten, committed: CHANGELOG_BEFORE_17_3_FIXTURE, working: CHANGELOG_FIXTURE }); + try { + const first = syncIndex({ root: ga.dir, date: STAMP_DATE }); + expect('I — syncIndex on the GA version run re-dates the field ON DISK', + first.wrote === true && first.rewrites.length === 1 && first.rewrites[0].redated === true + && readFileSync(join(ga.dir, INDEX_PATH), 'utf8') === alreadyDated && first.findings.length === 0); + + // The version commit lands; the next run on a later day finds `## 17.3.0` at HEAD. + ga.git('add', '-A'); + ga.git('commit', '-q', '--no-gpg-sign', '-m', 'chore: version packages'); + const next = syncIndex({ root: ga.dir, date: '2026-09-10' }); + expect('I — once the version commit is at HEAD, a later run leaves the date alone, unwritten', + next.wrote === false && next.rewrites.length === 0 && next.gaVersionRun.length === 0 + && readFileSync(join(ga.dir, INDEX_PATH), 'utf8') === alreadyDated); + } finally { + rmSync(ga.dir, { recursive: true, force: true }); + } + + const headless = checkoutTree({ index: prewritten, committed: null, working: CHANGELOG_FIXTURE }); + try { + let refused = false; + try { syncIndex({ root: headless.dir, date: STAMP_DATE }); } catch { refused = true; } + expect('I — a CHANGELOG git cannot read at HEAD is a REFUSAL, and the index is left untouched', + refused && readFileSync(join(headless.dir, INDEX_PATH), 'utf8') === prewritten); + } finally { + rmSync(headless.dir, { recursive: true, force: true }); + } + } + // ── Floor ───────────────────────────────────────────────────────────────── const declared = Object.keys(SELF_TEST_BATTERIES); let floorBreached = false; @@ -770,7 +1044,8 @@ export function selfTest() { + 'unwritten, the shapes that need a human sentence ("final release:", a missing parenthetical, ' + 'prose) are REFUSED and reach the gate\'s own verdict instead, prose parentheticals and ' + 'out-of-scope majors are never touched, a prerelease cut (`next` or `rc`) leaves the newest ' - + 'GA\'s release date alone, and the surface, the scope floor and the verdict are the gate\'s ' + + 'GA\'s release date alone while the GA\'s own version run re-dates a pre-written field (per ' + + 'major, read from the CHANGELOG at HEAD), and the surface, the scope floor and the verdict are the gate\'s ' + 'rather than copies of it.', ); selfTestReachedVerdict = true;