mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-25 08:32:11 +03:00
fix(changelog): require verified reconciliation ledger
This commit is contained in:
@@ -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 <base> -- 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user