From dc1fb437b6344d58b2b735da51daa4c7c8d79ca3 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Mon, 24 Aug 2026 02:20:19 -0300 Subject: [PATCH] fix(changelog): require verified reconciliation ledger --- .env.example | 6 +- ...pending-changelog-reconciliation-ledger.md | 1 + config/release/changelog-reconciliations.json | 4 + docs/reference/ENVIRONMENT.md | 1 - scripts/check/check-changelog-integrity.mjs | 256 +++++++++++++-- scripts/release/merge-train.sh | 6 +- tests/unit/check-changelog-integrity.test.ts | 297 +++++++++++++++++- tests/unit/merge-train-plan.test.ts | 72 ++++- 8 files changed, 606 insertions(+), 37 deletions(-) create mode 100644 changelog.d/maintenance/pending-changelog-reconciliation-ledger.md create mode 100644 config/release/changelog-reconciliations.json diff --git a/.env.example b/.env.example index acf629530c..c1a1f06e67 100644 --- a/.env.example +++ b/.env.example @@ -2433,10 +2433,10 @@ APP_LOG_TO_FILE=true # test suite must NEVER mutate the OS trust store (a fake test PEM installed via # update-ca-certificates broke all system TLS on a persistent runner, 2026-07-05). # OMNIROUTE_SKIP_SYSTEM_TRUST=1 -# check-changelog-integrity.mjs (anti CHANGELOG-eat gate): explicit base ref -# override, and the justified-removal escape hatch for intentional bullet removals. +# check-changelog-integrity.mjs (anti CHANGELOG-eat gate): explicit base ref override. +# Intentional transformations require an exact reviewed entry in +# config/release/changelog-reconciliations.json; there is no runtime bypass. # CHANGELOG_BASE_REF=origin/release/v0.0.0 -# ALLOW_CHANGELOG_REMOVALS=1 # ── Remote audio provider nodes ── # Used by: src/app/api/v1/_shared/audioProviderNodes.ts — lets the /v1/audio/* diff --git a/changelog.d/maintenance/pending-changelog-reconciliation-ledger.md b/changelog.d/maintenance/pending-changelog-reconciliation-ledger.md new file mode 100644 index 0000000000..8a933676d5 --- /dev/null +++ b/changelog.d/maintenance/pending-changelog-reconciliation-ledger.md @@ -0,0 +1 @@ +- **ci(changelog):** replace the broad removal bypass with an exact, hash-bound reconciliation ledger and bind merge-train checks to their requested release base. diff --git a/config/release/changelog-reconciliations.json b/config/release/changelog-reconciliations.json new file mode 100644 index 0000000000..db944ff34b --- /dev/null +++ b/config/release/changelog-reconciliations.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, + "reconciliations": [] +} diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 2b5a82a231..b383b5696f 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -1281,7 +1281,6 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `OMNIROUTE_SKIP_DNS_WRITE` | _(unset)_ | `src/mitm/dns/dnsConfig.ts` | Set `1` to skip writing to the hosts file when adding/removing DNS entries — for sandboxed or read-only test environments. | | `OMNIROUTE_SKIP_SYSTEM_TRUST` | `0` | `src/mitm/cert/install.ts`, `src/mitm/tproxy/caTrust.ts` | Test/CI-only guard: set `1` to make cert trust install/uninstall a no-op so the suite never mutates the OS trust store. Set automatically by the test setup and CI workflows. | | `CHANGELOG_BASE_REF` | _(auto)_ | `scripts/check/check-changelog-integrity.mjs` | Explicit base ref for the anti CHANGELOG-eat gate (defaults to the PR base branch in CI, or the highest `release/v*`). | -| `ALLOW_CHANGELOG_REMOVALS` | `0` | `scripts/check/check-changelog-integrity.mjs` | Set `1` to turn intentional CHANGELOG bullet removals into a report instead of a failure (justify in the PR body). | | `ONEPROXY_ENABLED` | `true` | `src/lib/oneproxySync.ts` | Enable the 1Proxy egress pool sync. | | `ONEPROXY_API_URL` | `https://1proxy-api.aitradepulse.com` | `src/lib/oneproxySync.ts` | 1Proxy service API URL override. | | `ONEPROXY_MAX_PROXIES` | `500` | `src/lib/oneproxySync.ts` | Maximum proxies imported per sync. | diff --git a/scripts/check/check-changelog-integrity.mjs b/scripts/check/check-changelog-integrity.mjs index edf3ac4442..1eb11a26e8 100644 --- a/scripts/check/check-changelog-integrity.mjs +++ b/scripts/check/check-changelog-integrity.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node // scripts/check/check-changelog-integrity.mjs // -// Anti "CHANGELOG-eat" gate: no bullet line that exists in the BASE branch's -// CHANGELOG.md may disappear in the merge result. The chronic failure mode is +// Anti "CHANGELOG-eat" gate: no bullet-line occurrence that exists in the BASE +// branch's CHANGELOG.md may disappear in the merge result. The chronic failure mode is // git's merge auto-resolve silently dropping sibling bullets (or whole version // sections) when two branches touch adjacent CHANGELOG lines — incident // 2026-07-05: PR #6193's merge ate 212 lines (the entire [3.8.45] + [3.8.44] @@ -16,47 +16,221 @@ // quality.yml runs it blocking for own-origin PRs and report-only for forks. // The release captain's reconciliation rewrites the CHANGELOG legitimately, // but that happens on the release PR (PR → main, ci.yml), which does not run -// this gate. Escape hatch for intentional removals (e.g. reverting a reverted -// feature's bullet): ALLOW_CHANGELOG_REMOVALS=1 turns failures into a report. +// this gate. There is no runtime escape hatch: every unexplained removal fails. +// Intentional rewrites require a reviewed record in +// config/release/changelog-reconciliations.json. Each record binds the complete base +// and result files by SHA-256 and lists the exact removed/added bullet-line multiset; +// repeated strings encode repeated occurrences. The gate deliberately protects +// bullet lines, not standalone headings, dates, or prose outside a bullet. // // Usage: // node scripts/check/check-changelog-integrity.mjs // env GITHUB_BASE_REF PR base branch (CI); local fallback: current release/* // env CHANGELOG_BASE_REF explicit ref override (e.g. origin/release/v3.8.45) -// env ALLOW_CHANGELOG_REMOVALS=1 report-only (never fails) import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { existsSync, readFileSync, readdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const CHANGELOG = "CHANGELOG.md"; +const RECONCILIATIONS = "config/release/changelog-reconciliations.json"; const FRAGMENTS_DIR = "changelog.d"; const FRAGMENT_SECTIONS = ["features", "fixes", "maintenance"]; const FRAGMENT_SKIP = new Set(["README.md", ".gitkeep"]); +const SHA256_PATTERN = /^[0-9a-f]{64}$/; +const RECONCILIATION_KEYS = new Set([ + "id", + "reason", + "baseChangelogSha256", + "resultChangelogSha256", + "removedBullets", + "addedBullets", +]); /** Extract the set of bullet lines (trimmed) from a CHANGELOG text. */ export function extractBullets(text) { - const bullets = new Set(); + return new Set(extractBulletOccurrences(text)); +} + +/** Extract every bullet-line occurrence, preserving order and duplicates. */ +export function extractBulletOccurrences(text) { + const bullets = []; for (const raw of String(text || "").split("\n")) { const line = raw.trim(); - if (line.startsWith("- ") && line.length > 4) bullets.add(line); + if (line.startsWith("- ") && line.length > 4) bullets.push(line); } return bullets; } +function findMissingOccurrences(sourceText, targetText) { + const available = new Map(); + for (const bullet of extractBulletOccurrences(targetText)) { + available.set(bullet, (available.get(bullet) || 0) + 1); + } + const missing = []; + for (const bullet of extractBulletOccurrences(sourceText)) { + const count = available.get(bullet) || 0; + if (count > 0) available.set(bullet, count - 1); + else missing.push(bullet); + } + return missing; +} + /** - * Bullet lines present in the base CHANGELOG but absent from the head - * CHANGELOG — the "eaten" set. Pure so it has a unit test. + * Bullet-line occurrences present in the base CHANGELOG but absent from the head + * CHANGELOG — including one lost copy of a repeated line. Pure so it has a unit test. */ export function findLostBullets(baseText, headText) { - const headBullets = extractBullets(headText); - const lost = []; - for (const b of extractBullets(baseText)) { - if (!headBullets.has(b)) lost.push(b); + return findMissingOccurrences(baseText, headText); +} + +/** Bullet-line occurrences present only in the result CHANGELOG. */ +export function findAddedBullets(baseText, headText) { + return findMissingOccurrences(headText, baseText); +} + +/** Stable digest tying a reconciliation record to the complete file, not just its bullets. */ +export function changelogSha256(text) { + return createHash("sha256") + .update(String(text || ""), "utf8") + .digest("hex"); +} + +function validateBulletList(value, path, { allowEmpty }) { + if (!Array.isArray(value)) return [`${path} must be an array`]; + const errors = []; + if (!allowEmpty && value.length === 0) errors.push(`${path} must not be empty`); + for (let index = 0; index < value.length; index++) { + const bullet = value[index]; + if ( + typeof bullet !== "string" || + bullet !== bullet.trim() || + !bullet.startsWith("- ") || + bullet.length <= 4 + ) { + errors.push(`${path}[${index}] must be one exact, trimmed markdown bullet`); + } } - return lost; + return errors; +} + +/** Validate the durable reconciliation ledger without trusting any of its claims. */ +export function validateReconciliationLedger(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return ["ledger must be a JSON object"]; + } + const errors = []; + const topLevelKeys = Object.keys(value); + for (const key of topLevelKeys) { + if (key !== "schemaVersion" && key !== "reconciliations") { + errors.push(`unknown top-level field: ${key}`); + } + } + if (value.schemaVersion !== 1) errors.push("schemaVersion must be 1"); + if (!Array.isArray(value.reconciliations)) { + errors.push("reconciliations must be an array"); + return errors; + } + + const ids = new Set(); + const filePairs = new Set(); + for (let index = 0; index < value.reconciliations.length; index++) { + const record = value.reconciliations[index]; + const path = `reconciliations[${index}]`; + if (!record || typeof record !== "object" || Array.isArray(record)) { + errors.push(`${path} must be an object`); + continue; + } + for (const key of Object.keys(record)) { + if (!RECONCILIATION_KEYS.has(key)) errors.push(`${path} has unknown field: ${key}`); + } + if (typeof record.id !== "string" || !/^[a-z0-9][a-z0-9._-]{2,79}$/.test(record.id)) { + errors.push(`${path}.id must be a 3-80 character lowercase slug`); + } else if (ids.has(record.id)) { + errors.push(`${path}.id duplicates "${record.id}"`); + } else { + ids.add(record.id); + } + if (typeof record.reason !== "string" || record.reason.trim().length < 20) { + errors.push(`${path}.reason must explain the reconciliation in at least 20 characters`); + } + if (!SHA256_PATTERN.test(record.baseChangelogSha256 || "")) { + errors.push(`${path}.baseChangelogSha256 must be a lowercase SHA-256 digest`); + } + if (!SHA256_PATTERN.test(record.resultChangelogSha256 || "")) { + errors.push(`${path}.resultChangelogSha256 must be a lowercase SHA-256 digest`); + } + if ( + SHA256_PATTERN.test(record.baseChangelogSha256 || "") && + record.baseChangelogSha256 === record.resultChangelogSha256 + ) { + errors.push(`${path} must describe a changed CHANGELOG.md`); + } + errors.push( + ...validateBulletList(record.removedBullets, `${path}.removedBullets`, { + allowEmpty: false, + }), + ...validateBulletList(record.addedBullets, `${path}.addedBullets`, { allowEmpty: true }) + ); + if (Array.isArray(record.removedBullets) && Array.isArray(record.addedBullets)) { + const removed = new Set(record.removedBullets); + for (const bullet of record.addedBullets) { + if (removed.has(bullet)) errors.push(`${path} lists the same bullet as removed and added`); + } + } + + const pair = `${record.baseChangelogSha256}:${record.resultChangelogSha256}`; + if (filePairs.has(pair)) errors.push(`${path} duplicates an earlier base/result digest pair`); + filePairs.add(pair); + } + return errors; +} + +function sameStringMultiset(left, right) { + if (left.length !== right.length) return false; + const remaining = new Map(); + for (const item of right) remaining.set(item, (remaining.get(item) || 0) + 1); + for (const item of left) { + const count = remaining.get(item) || 0; + if (count === 0) return false; + remaining.set(item, count - 1); + } + return true; +} + +/** Find the single record that exactly explains this complete base → result transition. */ +export function findLedgeredReconciliation(baseText, headText, ledger) { + const baseChangelogSha256 = changelogSha256(baseText); + const resultChangelogSha256 = changelogSha256(headText); + const removedBullets = findLostBullets(baseText, headText); + const addedBullets = findAddedBullets(baseText, headText); + return ledger.reconciliations.find( + (record) => + record.baseChangelogSha256 === baseChangelogSha256 && + record.resultChangelogSha256 === resultChangelogSha256 && + sameStringMultiset(record.removedBullets, removedBullets) && + sameStringMultiset(record.addedBullets, addedBullets) + ); +} + +function readReconciliationLedger(root = ROOT) { + const path = join(root, RECONCILIATIONS); + if (!existsSync(path)) { + return { ledger: null, errors: [`${RECONCILIATIONS} is missing`] }; + } + let ledger; + try { + ledger = JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + return { + ledger: null, + errors: [`${RECONCILIATIONS} is not valid JSON: ${error.message}`], + }; + } + return { ledger, errors: validateReconciliationLedger(ledger) }; } /** @@ -111,7 +285,13 @@ function resolveBaseRef() { if (process.env.GITHUB_BASE_REF) return `origin/${process.env.GITHUB_BASE_REF}`; // Local fallback: the highest release/v* on origin (the active development base). try { - const branches = git(["branch", "-r", "--list", "origin/release/v*", "--format=%(refname:short)"]) + const branches = git([ + "branch", + "-r", + "--list", + "origin/release/v*", + "--format=%(refname:short)", + ]) .split("\n") .map((s) => s.trim()) .filter(Boolean) @@ -123,16 +303,33 @@ function resolveBaseRef() { } function main() { + if (Object.hasOwn(process.env, "ALLOW_CHANGELOG_REMOVALS")) { + console.error( + "[changelog-integrity] ALLOW_CHANGELOG_REMOVALS was removed; delete it from the environment and record intentional transformations in config/release/changelog-reconciliations.json." + ); + return 1; + } + // Fragment well-formedness first (changelog.d/ — the fragments pattern makes the // eat-guard below structurally unnecessary for PRs that stop editing CHANGELOG.md). const invalidFragments = findInvalidFragments(); if (invalidFragments.length > 0) { - console.error(`[changelog-integrity] ${invalidFragments.length} invalid changelog fragment(s):`); + console.error( + `[changelog-integrity] ${invalidFragments.length} invalid changelog fragment(s):` + ); for (const { file, error } of invalidFragments) console.error(` ✗ ${file}: ${error}`); console.error("\nSee changelog.d/README.md for the fragment convention."); return 1; } + const { ledger, errors: ledgerErrors } = readReconciliationLedger(); + if (ledgerErrors.length > 0) { + console.error(`[changelog-integrity] invalid reconciliation ledger (${ledgerErrors.length}):`); + for (const error of ledgerErrors) console.error(` ✗ ${error}`); + return 1; + } + + const hasExplicitBaseRef = Boolean(process.env.CHANGELOG_BASE_REF || process.env.GITHUB_BASE_REF); const baseRef = resolveBaseRef(); if (!baseRef) { console.log("[changelog-integrity] SKIP — could not resolve a base ref (offline/fresh clone)."); @@ -143,6 +340,12 @@ function main() { try { baseText = git(["show", `${baseRef}:${CHANGELOG}`]); } catch { + if (hasExplicitBaseRef) { + console.error( + `[changelog-integrity] FAIL — ${CHANGELOG} not readable at explicit base ${baseRef}.` + ); + return 1; + } console.log(`[changelog-integrity] SKIP — ${CHANGELOG} not readable at ${baseRef}.`); return 0; } @@ -154,21 +357,30 @@ function main() { return 0; } + const reconciliation = findLedgeredReconciliation(baseText, headText, ledger); + if (reconciliation) { + console.log( + `[changelog-integrity] OK — ${lost.length} removed base bullet(s) covered by ledgered reconciliation "${reconciliation.id}" vs ${baseRef}.` + ); + return 0; + } + console.error( `[changelog-integrity] ${lost.length} bullet(s) present in ${baseRef} are MISSING from this tree's ${CHANGELOG}:` ); for (const b of lost.slice(0, 15)) console.error(` ✗ ${b.slice(0, 160)}`); if (lost.length > 15) console.error(` … and ${lost.length - 15} more`); + const added = findAddedBullets(baseText, headText); console.error( "\nThis is the CHANGELOG-eat pattern (merge auto-resolve dropping sibling bullets)." + "\nFix: restore the base CHANGELOG (`git checkout -- CHANGELOG.md`), re-insert ONLY" + - "\nyour own bullet, and prove the net diff is additive. Intentional removals (rare):" + - "\nre-run with ALLOW_CHANGELOG_REMOVALS=1 and justify in the PR body." + "\nyour own bullet, and prove the net diff is additive." + + `\nIntentional reconciliation: add one exact, reviewed record to ${RECONCILIATIONS}.` + + `\n baseChangelogSha256: ${changelogSha256(baseText)}` + + `\n resultChangelogSha256: ${changelogSha256(headText)}` + + `\n removedBullets: ${lost.length}; addedBullets: ${added.length}` + + "\nThere is no environment-variable bypass." ); - if (process.env.ALLOW_CHANGELOG_REMOVALS === "1") { - console.error("[changelog-integrity] ALLOW_CHANGELOG_REMOVALS=1 — reporting only, not failing."); - return 0; - } return 1; } diff --git a/scripts/release/merge-train.sh b/scripts/release/merge-train.sh index 613ab53d7e..bc3b783537 100755 --- a/scripts/release/merge-train.sh +++ b/scripts/release/merge-train.sh @@ -57,12 +57,16 @@ for N in "${PRS[@]}"; do done ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" +# The train worktree is detached, so the changelog gate cannot infer which release +# branch seeded it. Shell-quote the requested base before it enters the eval-backed +# gate list, then bind that exact ref only for the changelog check. +printf -v CHANGELOG_BASE_REF_Q '%q' "origin/${BASE}" STATIC_GATES=( "npm run typecheck:core" "node scripts/check/check-file-size.mjs" "node scripts/check/check-complexity.mjs" "node scripts/check/check-cognitive-complexity.mjs" - "node scripts/check/check-changelog-integrity.mjs" + "env CHANGELOG_BASE_REF=${CHANGELOG_BASE_REF_Q} node scripts/check/check-changelog-integrity.mjs" ) # Full mode: the box-speed runner (same coverage as the two CI shards combined — # main + dashboard + serial groups — at local concurrency instead of runner-sized). diff --git a/tests/unit/check-changelog-integrity.test.ts b/tests/unit/check-changelog-integrity.test.ts index a06bf4318e..b78c1c5a82 100644 --- a/tests/unit/check-changelog-integrity.test.ts +++ b/tests/unit/check-changelog-integrity.test.ts @@ -4,10 +4,20 @@ // PR #6193: 212 lines / 130 bullets eaten). import { test } from "node:test"; import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; -const { extractBullets, findLostBullets } = await import( - "../../scripts/check/check-changelog-integrity.mjs" +const { extractBullets, findLostBullets } = + await import("../../scripts/check/check-changelog-integrity.mjs"); + +const SCRIPT_PATH = fileURLToPath( + new URL("../../scripts/check/check-changelog-integrity.mjs", import.meta.url) ); +const LEDGER_PATH = "config/release/changelog-reconciliations.json"; const BASE = `# Changelog @@ -46,13 +56,288 @@ test("detects a whole eaten version section (#6193 pattern)", () => { assert.deepEqual(lost, ["- **feat(c):** shipped bullet ([#3](https://x/3))"]); }); +test("detects one lost occurrence when an identical bullet still exists elsewhere", () => { + const duplicate = "- **fix(repeated):** same rendered bullet ([#9](https://x/9))"; + const base = `${BASE}${duplicate}\n${duplicate}\n`; + const head = `${BASE}${duplicate}\n`; + + assert.deepEqual(findLostBullets(base, head), [duplicate]); +}); + test("bullets moved between sections are NOT reported (line content preserved)", () => { - const head = BASE.replace( - "- **fix(a):** first bullet ([#1](https://x/1))\n", - "" - ).replace( + const head = BASE.replace("- **fix(a):** first bullet ([#1](https://x/1))\n", "").replace( "- **feat(c):** shipped bullet ([#3](https://x/3))", "- **feat(c):** shipped bullet ([#3](https://x/3))\n- **fix(a):** first bullet ([#1](https://x/1))" ); assert.deepEqual(findLostBullets(BASE, head), []); }); + +function makeCliRepo(baseText = BASE) { + const root = mkdtempSync(join(tmpdir(), "changelog-integrity-cli-")); + const script = join(root, "scripts/check/check-changelog-integrity.mjs"); + mkdirSync(dirname(script), { recursive: true }); + mkdirSync(join(root, "changelog.d/features"), { recursive: true }); + mkdirSync(join(root, "changelog.d/fixes"), { recursive: true }); + mkdirSync(join(root, "changelog.d/maintenance"), { recursive: true }); + mkdirSync(join(root, "config/release"), { recursive: true }); + writeFileSync(script, readFileSync(SCRIPT_PATH, "utf8")); + writeFileSync(join(root, "CHANGELOG.md"), baseText); + writeLedger(root, []); + execFileSync("git", ["init", "--quiet"], { cwd: root }); + execFileSync("git", ["add", "."], { cwd: root }); + execFileSync( + "git", + [ + "-c", + "user.name=Changelog Integrity Test", + "-c", + "user.email=changelog-integrity@example.invalid", + "commit", + "--quiet", + "-m", + "base", + ], + { cwd: root } + ); + const baseRef = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: root, + encoding: "utf8", + }).trim(); + return { root, baseRef }; +} + +function sha256(text) { + return createHash("sha256").update(text, "utf8").digest("hex"); +} + +function writeLedger(root, reconciliations) { + writeFileSync( + join(root, LEDGER_PATH), + `${JSON.stringify({ schemaVersion: 1, reconciliations }, null, 2)}\n` + ); +} + +function runCli(root, baseRef, extraEnv = {}) { + return spawnSync(process.execPath, ["scripts/check/check-changelog-integrity.mjs"], { + cwd: root, + encoding: "utf8", + env: { ...process.env, CHANGELOG_BASE_REF: baseRef, ...extraEnv }, + }); +} + +test("CLI rejects an unledgered loss", () => { + const { root, baseRef } = makeCliRepo(); + try { + writeFileSync( + join(root, "CHANGELOG.md"), + BASE.replace("- **fix(b):** second bullet ([#2](https://x/2))\n", "") + ); + + const result = runCli(root, baseRef); + + assert.equal(result.status, 1, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /1 bullet\(s\).*MISSING/s); + assert.doesNotMatch(result.stderr, /reporting only, not failing/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("CLI fails closed when the removed legacy bypass is still configured", () => { + const { root, baseRef } = makeCliRepo(); + try { + const result = runCli(root, baseRef, { ALLOW_CHANGELOG_REMOVALS: "1" }); + + assert.equal(result.status, 1, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /ALLOW_CHANGELOG_REMOVALS.*removed/); + assert.match(result.stderr, /changelog-reconciliations\.json/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("CLI accepts only an exact, reviewable ledgered reconciliation", () => { + const { root, baseRef } = makeCliRepo(); + try { + const removed = "- **fix(b):** second bullet ([#2](https://x/2))"; + const added = "- **fix(b):** clarified replacement bullet ([#2](https://x/2))"; + const resultText = BASE.replace(removed, added); + writeFileSync(join(root, "CHANGELOG.md"), resultText); + writeLedger(root, [ + { + id: "clarify-fix-b", + reason: "Clarify the wording while preserving the original fix and pull request reference.", + baseChangelogSha256: sha256(BASE), + resultChangelogSha256: sha256(resultText), + removedBullets: [removed], + addedBullets: [added], + }, + ]); + + const result = runCli(root, baseRef); + + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stdout, /OK.*ledgered reconciliation "clarify-fix-b"/s); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("CLI keeps an additional loss RED after an approved result is tampered with", () => { + const { root, baseRef } = makeCliRepo(); + try { + const removed = "- **fix(b):** second bullet ([#2](https://x/2))"; + const added = "- **fix(b):** clarified replacement bullet ([#2](https://x/2))"; + const approvedResult = BASE.replace(removed, added); + writeLedger(root, [ + { + id: "clarify-fix-b", + reason: "Clarify the wording while preserving the original fix and pull request reference.", + baseChangelogSha256: sha256(BASE), + resultChangelogSha256: sha256(approvedResult), + removedBullets: [removed], + addedBullets: [added], + }, + ]); + const tamperedResult = approvedResult.replace( + "- **fix(a):** first bullet ([#1](https://x/1))\n", + "" + ); + writeFileSync(join(root, "CHANGELOG.md"), tamperedResult); + + const result = runCli(root, baseRef); + + assert.equal(result.status, 1, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /2 bullet\(s\).*MISSING/s); + assert.doesNotMatch(result.stdout, /ledgered reconciliation/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("CLI rejects exact file hashes when the ledger omits one removed occurrence", () => { + const { root, baseRef } = makeCliRepo(); + try { + const removedA = "- **fix(a):** first bullet ([#1](https://x/1))"; + const removedB = "- **fix(b):** second bullet ([#2](https://x/2))"; + const added = "- **fix(ab):** consolidated replacement ([#2](https://x/2))"; + const resultText = BASE.replace(`${removedA}\n${removedB}`, added); + writeFileSync(join(root, "CHANGELOG.md"), resultText); + writeLedger(root, [ + { + id: "incomplete-removed-multiset", + reason: "Deliberately incomplete fixture that must not authorize the full transition.", + baseChangelogSha256: sha256(BASE), + resultChangelogSha256: sha256(resultText), + removedBullets: [removedB], + addedBullets: [added], + }, + ]); + + const result = runCli(root, baseRef); + + assert.equal(result.status, 1, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /2 bullet\(s\).*MISSING/s); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("CLI rejects exact file hashes when the ledger omits one removed duplicate", () => { + const duplicate = "- **fix(repeated):** same rendered bullet ([#9](https://x/9))"; + const baseText = `${BASE}${duplicate}\n${duplicate}\n`; + const { root, baseRef } = makeCliRepo(baseText); + try { + const added = "- **fix(repeated):** consolidated duplicate ([#9](https://x/9))"; + const resultText = `${BASE}${added}\n`; + writeFileSync(join(root, "CHANGELOG.md"), resultText); + writeLedger(root, [ + { + id: "incomplete-duplicate-multiset", + reason: "Deliberately omit one identical occurrence from the declared transition.", + baseChangelogSha256: sha256(baseText), + resultChangelogSha256: sha256(resultText), + removedBullets: [duplicate], + addedBullets: [added], + }, + ]); + + const result = runCli(root, baseRef); + + assert.equal(result.status, 1, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /2 bullet\(s\).*MISSING/s); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("CLI rejects exact bullet deltas when the ledger base hash is wrong", () => { + const { root, baseRef } = makeCliRepo(); + try { + const removed = "- **fix(b):** second bullet ([#2](https://x/2))"; + const added = "- **fix(b):** clarified replacement bullet ([#2](https://x/2))"; + const resultText = BASE.replace(removed, added); + writeFileSync(join(root, "CHANGELOG.md"), resultText); + writeLedger(root, [ + { + id: "wrong-base-hash", + reason: "Deliberately stale base digest that must not authorize this transition.", + baseChangelogSha256: "0".repeat(64), + resultChangelogSha256: sha256(resultText), + removedBullets: [removed], + addedBullets: [added], + }, + ]); + + const result = runCli(root, baseRef); + + assert.equal(result.status, 1, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /1 bullet\(s\).*MISSING/s); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("CLI validates a new fragment without treating it as a reconciliation", () => { + const { root, baseRef } = makeCliRepo(); + try { + writeFileSync( + join(root, "changelog.d/fixes/11326-new-valid-fragment.md"), + "- **fix(kie):** preserve a newly added valid fragment ([#11326](https://x/11326)).\n" + ); + + const result = runCli(root, baseRef); + + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stdout, /OK — no base bullets lost/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("CLI fails closed on a malformed reconciliation ledger", () => { + const { root, baseRef } = makeCliRepo(); + try { + writeFileSync(join(root, LEDGER_PATH), '{"schemaVersion":1,"reconciliations":"all"}\n'); + + const result = runCli(root, baseRef); + + assert.equal(result.status, 1, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /invalid reconciliation ledger/); + assert.match(result.stderr, /reconciliations must be an array/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("CLI fails closed when an explicit base ref is unreadable", () => { + const { root } = makeCliRepo(); + try { + const result = runCli(root, "missing-explicit-base"); + + assert.equal(result.status, 1, `${result.stdout}\n${result.stderr}`); + assert.match(result.stderr, /FAIL.*CHANGELOG\.md.*missing-explicit-base/s); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/merge-train-plan.test.ts b/tests/unit/merge-train-plan.test.ts index 1468041c99..f3b7954521 100644 --- a/tests/unit/merge-train-plan.test.ts +++ b/tests/unit/merge-train-plan.test.ts @@ -5,13 +5,17 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; -import { readFile } from "node:fs/promises"; +import { access, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { promisify } from "node:util"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const pExecFile = promisify(execFile); -const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "../../scripts/release/merge-train.sh"); +const SCRIPT = join( + dirname(fileURLToPath(import.meta.url)), + "../../scripts/release/merge-train.sh" +); async function run(args: string[]) { try { @@ -68,6 +72,59 @@ test("--plan --fast swaps the full unit suite for changed-tests, keeps static ga assert.ok(!stdout.includes("npm run test:unit"), "fast mode must not run the full unit suite"); }); +test("--plan binds the changelog gate to the requested base inside the detached worktree", async () => { + const { code, stdout } = await run(["--plan", "release/v3.8.50", "11326"]); + assert.equal(code, 0); + assert.match( + stdout, + /worktree add .* --detach origin\/release\/v3\.8\.50/, + "the train worktree must remain detached from the requested base" + ); + assert.match( + stdout, + /env CHANGELOG_BASE_REF=origin\/release\/v3\.8\.50 node scripts\/check\/check-changelog-integrity\.mjs/, + "the gate must not fall back to a different numerically highest release branch" + ); +}); + +test("--plan shell-quotes a hostile base before the gate command is evaluated", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "merge-train-plan-")); + const dollarMarker = join(tempDir, "dollar-marker"); + const backtickMarker = join(tempDir, "backtick-marker"); + const semicolonMarker = join(tempDir, "semicolon-marker"); + const base = + `release/v9.9.9 $(touch ${dollarMarker}) ` + + `\`touch ${backtickMarker}\` whitespace gap ; touch ${semicolonMarker}`; + + try { + const { code, stdout } = await run(["--plan", base, "11326"]); + assert.equal(code, 0); + + const gateLine = stdout.split("\n").find((line) => line.includes("env CHANGELOG_BASE_REF=")); + assert.ok(gateLine, "the plan must include the changelog gate command"); + const plannedGate = gateLine.replace(/^\[merge-train\] \d+\. /, ""); + assert.ok( + !plannedGate.includes(`CHANGELOG_BASE_REF=origin/${base}`), + "hostile shell syntax must not appear unescaped in the eval-backed gate command" + ); + + // Exercise the exact plan command through the same eval boundary as the real + // train, replacing only the gate executable with a side-effect-free env probe. + const probe = plannedGate.replace( + "node scripts/check/check-changelog-integrity.mjs", + "printenv CHANGELOG_BASE_REF" + ); + const { stdout: evaluatedBase } = await pExecFile("bash", ["-c", 'eval "$1"', "bash", probe]); + assert.equal(evaluatedBase, `origin/${base}\n`); + + for (const marker of [dollarMarker, backtickMarker, semicolonMarker]) { + await assert.rejects(access(marker), { code: "ENOENT" }); + } + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + test("fast mode's UNIT_SUBDIRS allowlist mirrors package.json test:unit exactly", async () => { // Regression for the 2026-07-18 train red: tests/unit/autoCombo/ (a vitest-only // subdir) was fed to the node:test bucket because the fast filter had no subdir @@ -79,8 +136,15 @@ test("fast mode's UNIT_SUBDIRS allowlist mirrors package.json test:unit exactly" const pkg = JSON.parse(await readFile(new URL("../../package.json", import.meta.url), "utf8")); const pkgList = pkg.scripts["test:unit"].match(/tests\/unit\/\{([^}]+)\}/)?.[1]; assert.ok(pkgList, "package.json test:unit must carry the {subdir} allowlist glob"); - assert.equal(scriptList, pkgList, "merge-train.sh UNIT_SUBDIRS must equal test:unit's subdir set"); - assert.ok(!scriptList.split(",").includes("autoCombo"), "autoCombo belongs to vitest, not node:test"); + assert.equal( + scriptList, + pkgList, + "merge-train.sh UNIT_SUBDIRS must equal test:unit's subdir set" + ); + assert.ok( + !scriptList.split(",").includes("autoCombo"), + "autoCombo belongs to vitest, not node:test" + ); }); test("rejects an unknown flag", async () => {