mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 15:22:12 +03:00
* fix(ci): stop the reconciliation range and the fragment sweep from hiding work Two release-tooling defects found during the v3.8.49 run (gaps 4 and 7 of the process dossier). Both fail by hiding work rather than announcing themselves, which is why each one had already cost a real mistake. ## The reconciliation range was 62× too wide `list-uncovered-commits.mjs` bounded its scan with `git describe --tags`. Releases reach `main` by SQUASH, so no commit on a release branch is ever an ancestor of the tag, and `vPREV..HEAD` re-lists the un-squashed history of every earlier cycle. Measured on release/v3.8.50 @7eca04fd12: v3.8.49..HEAD ....... 1361 commits cycle open..HEAD ..... 22 commits The report drowns in noise, and that is how a previous reconciliation let ~200 PRs through with no CHANGELOG bullet. The base is now resolved by CONTENT — the oldest commit that introduced this version string into package.json — deliberately NOT by commit subject, because the subject has already changed format once: chore(release): bump v3.8.49 (development cycle version) older chore(release): open v3.8.50 development cycle current A message-matching resolver would have silently reverted to the broken tag base the first time someone reworded the bump. The fallback now writes a WARNING to stderr explaining that a tag range re-lists previous cycles, so a shallow clone degrades loudly instead of quietly reproducing the bug. One of the five tests asserts exactly that the warning says "squash" and "noise". ## The back-merge resurrects fragments that already shipped The release lands on `main` as one squash commit, so `main` still carries every `changelog.d/` fragment the reconciliation folded in and deleted. Back-merging `main` restores all of them — 191 in the v3.8.49 run. Nothing breaks at that instant; the next aggregation folds them in a SECOND time and the section grows duplicates that have to be hand-unpicked. New `scripts/release/sweep-stale-fragments.mjs` (`npm run sweep:stale-fragments`) reports them, and `--apply` removes them. Report mode exits 1 so the back-merge step can gate on it. The identity rule took two attempts, and the second one exists because running the script against the live repo refuted the first. Matching on any `#N` in the bullet flagged `changelog.d/features/8980-deprecate-gemini-cli-provider.md` as stale, because that bullet cites issue **#7034** for context and #7034 shipped in an earlier cycle — it would have deleted an unreleased fragment and dropped its credit. A bullet routinely cites issues it merely references; only the `<PR-number>-<slug>.md` filename says which PR the fragment *is*. That case is now a regression test. Every ambiguous case resolves toward KEEPING: no number in the filename falls back to normalized text, text shorter than 12 chars is never matched, and anything matching neither is kept. A surviving duplicate is a nuisance someone notices; a deleted fragment silently costs a contributor their credit. node --import tsx/esm --test tests/unit/release-cycle-base-resolver.test.ts # 5 pass node --import tsx/esm --test tests/unit/sweep-stale-fragments.test.ts # 11 pass node scripts/release/list-uncovered-commits.mjs --json → baseed2db6cb19, baseSource "cycle-open", total 22 (was 1361) node scripts/release/sweep-stale-fragments.mjs → 4 fragments, 0 stale, exit 0 * docs(changelog): fragment for #8985 * fix(ci): four quality gates that punished the wrong thing Gaps 6, 9, 10 and 23 of the v3.8.49 process dossier. Each one either blocked work it should have waved through, or reported a number that was never the code's. ## 6 — test-masking is unusable at release scale My own dossier entry for this was WRONG and the measurement says so: tracked test files ............ 3977 (I had written 1277) absolute tautology scan ....... ~1 s (I had written >30 min) the diff uses base...HEAD three dots — already merge-base diff vs release branch ........ 0 files, 0 s diff vs main (today) .......... 3 files, 0 s The base choice was never the problem, and it cannot be reproduced today at all: `main` has since received the v3.8.49 squash, so the merge-base is recent. The pathology only exists DURING a release, in the window before `main` gets the squash — then the merge-base is the PREVIOUS cycle's fork point and the diff legitimately spans the whole cycle (~1277 changed test files, each costing a `git show` process plus a full regex pass). That is the same squash-merge topology as gap 4, and it is why the check ran twice without finishing. Fix: above 300 changed test files the per-file diff subchecks are skipped, since every one of those files was already gated by this check on its own PR. The absolute tautology scan still runs unconditionally over all 3977 files, so the floor is untouched. The skip is deliberately loud — a silent skip is gap 12, which cost two production bugs this cycle. `shouldSkipDiffSubchecks` never skips on unparseable input, so a broken count cannot disable the gate. ## 9 — a capital letter invalidated 41 translations `"Reset Defaults"` → `"Reset defaults"` marked the key stale in 41 locales. Every translation was still correct, and in locales with no letter case the "fix" is not expressible. Worse, the escape hatch (`__MISSING__:`) is BANNED in `vi` by tests/unit/i18n-vi-completeness.test.ts, so `vi` had no legitimate way out. `isCosmeticRewrite` folds case, whitespace runs and trailing punctuation — and nothing else. Most of the nine tests exist to pin what is NOT cosmetic: a changed word, an added word, and any edit inside an interpolation like `{count}` all still flag. Two end-to-end tests hold both directions: a cosmetic edit leaves every locale alone, a real rewrite still flags all of them. ## 10 — the ratchet compared numbers from two different auditors `pipx install zizmor` was unpinned, so the runner installed whatever PyPI served that day and measured 1 finding MORE than the devbox on the identical commit (190 vs 189) — a second rebaseline push per release, chasing a number that was never the code's. Pinned to 1.25.2 (what the devbox runs), and check-workflows.mjs now prints `zizmorVersion=` next to the count so any future rebaseline is traceable to the tool that produced it. ## 23 — a PR pointed at its own branch #8912 has head == base == release/v3.8.50: no diff, can never merge, and it sits in the queue with a full check board on every push to that branch. It survived because nothing looks wrong — the checks pass, since there is nothing to check. New guard in the `changes` job (one field comparison, before anything is spent). The distinction that makes it safe to block on: an equal head/base BRANCH is conclusive, an equal head/base SHA is NOT — a branch cut moments ago has an identical tip and is legitimate, so that case warns instead of failing. Half a signal never fails either. node --import tsx/esm --test tests/unit/test-masking-release-scale.test.ts # 6 pass node --import tsx/esm --test tests/unit/ui-value-drift-cosmetic.test.ts # 9 pass node --import tsx/esm --test tests/unit/pr-self-target-guard.test.ts # 7 pass check:workflows --ratchet → 178 findings, zizmorVersion=zizmor 1.25.2, baseline 190 the i18n suite is unaffected (5 files re-run, all green) * fix(ci): allowlist the four CI-only env vars the new gates read The env-doc-sync gate failed three unit shards plus Docs Gates on this PR, and it was right to: it requires every `process.env.X` read in code to be documented in `.env.example`, and this PR introduced four new reads. They do not belong in `.env.example`. That file is OmniRoute's runtime configuration; these are CI signals with no meaning in a user's `.env`: HEAD_REF / HEAD_SHA / BASE_SHA the `changes` job passes github.head_ref, github.base_ref and the PR head/base SHAs to the self-targeting-PR guard TEST_MASKING_MAX_CHANGED_TESTS the escape hatch that raises the test-masking gate's release-scale skip threshold So they go in IGNORE_FROM_CODE, which exists for exactly this and already carries the precedent one line above: `BASE_REF`, allowlisted because CI passes it to the OpenAPI breaking-change gate. `BASE_REF` being already listed is also why only four of my five reads failed. Each entry carries its justification and the script that reads it, per the allowlist policy. node --import tsx/esm --test tests/unit/issue-7793-env-doc-sync-repro.test.ts # 1 pass npm run check:env-doc-sync → all three directions in sync * fix(i18n): narrow the cosmetic-rewrite exemption to the scope actually reported The gap-9 fix folded whitespace in addition to case, and that collided with a pre-existing test which pins the opposite — tests/unit/i18n-ui-value-drift.test.ts, "a value that only changes whitespace still counts as an edit". Its comment states the reasoning: Conservative on purpose: trailing-space churn is rare, and treating it as a no-op would let a real reword slip through behind an innocuous-looking diff. That is a documented decision by whoever wrote it. The problem actually reported was CASE — `"Reset Defaults"` → `"Reset defaults"` invalidating 41 correct translations — and whitespace was scope I added on my own. Reversing someone else's reasoned call, silently, to fix something nobody reported is not this change's job, so the exemption is narrowed to case + trailing terminal punctuation. No test pins either of those. The reported case is still fixed, verified end to end: that rewrite invalidates 0 locales. And whitespace is now asserted NON-cosmetic in my own test file too, so a later tidy-up cannot quietly fold it back in. node --import tsx/esm --test tests/unit/i18n-ui-value-drift.test.ts # 11 pass (pre-existing) node --import tsx/esm --test tests/unit/ui-value-drift-cosmetic.test.ts # 10 pass --------- Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
239 lines
9.8 KiB
JavaScript
239 lines
9.8 KiB
JavaScript
#!/usr/bin/env node
|
||
// Reconciliation helper: list non-merge commits since the last tag whose PR/issue ref is NOT
|
||
// represented in the current version's CHANGELOG section (or [Unreleased]).
|
||
//
|
||
// WHY: during the cycle, PRs merge into release/** and some land WITHOUT a CHANGELOG bullet, so
|
||
// /generate-release reconciliation has to rediscover them by hand (v3.8.43: 123 of 176 commits had
|
||
// no bullet). This surfaces exactly that gap in seconds — maintainer-side, non-blocking, run it at
|
||
// reconciliation (Phase 0a) so the release CHANGELOG is complete before the PR opens.
|
||
//
|
||
// A commit is "covered" iff ANY `#N` in its subject appears anywhere in the CHANGELOG scan window
|
||
// (the version section + [Unreleased]) — matching on issue OR PR number, since a bullet may cite
|
||
// either. Internal commits (chore/ci/test/refactor) are listed under "rollup candidates" so the
|
||
// maintainer can consolidate rather than write one bullet each.
|
||
//
|
||
// Usage: node scripts/release/list-uncovered-commits.mjs [--json]
|
||
// Exit: 0 always (advisory). Prints a report to stdout.
|
||
|
||
import { execFileSync } from "node:child_process";
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
|
||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||
const git = (args) => execFileSync("git", args, { cwd: ROOT, encoding: "utf8" }).trim();
|
||
|
||
const ROLLUP_TYPES = new Set(["chore", "ci", "test", "refactor", "build", "docs", "style"]);
|
||
|
||
/**
|
||
* Pick the commit that OPENED the current development cycle, and use that as the range base.
|
||
*
|
||
* `git describe --tags` is wrong here, and wrong in a way that hides work rather than
|
||
* announcing itself. Releases reach `main` by SQUASH, so no commit on a release branch is
|
||
* ever an ancestor of the tag — `v3.8.49..HEAD` therefore re-lists the un-squashed history
|
||
* of every prior cycle. Measured on release/v3.8.50 at 7eca04fd12:
|
||
*
|
||
* v3.8.49..HEAD ............ 1361 commits
|
||
* cycle open..HEAD ......... 22 commits
|
||
*
|
||
* 62× the real range. The report drowns in noise, which is exactly how a previous
|
||
* reconciliation let ~200 PRs through with no CHANGELOG bullet.
|
||
*
|
||
* Resolved by CONTENT, not by commit message: the cycle opens when package.json first
|
||
* carries this version, so `git log -S` on that exact string finds it regardless of how the
|
||
* subject was worded. The wording has already changed once —
|
||
* `chore(release): bump v3.8.49 (development cycle version)` became
|
||
* `chore(release): open v3.8.50 development cycle` — and a message-matching resolver would
|
||
* have silently fallen back to the broken tag base on the newer format.
|
||
*
|
||
* Falls back to the tag with an explicit stderr warning, so a shallow clone or a rewritten
|
||
* history degrades loudly instead of quietly reproducing the original bug.
|
||
*/
|
||
export function resolveCycleBase(version, runGit = git) {
|
||
let openCommit = "";
|
||
try {
|
||
// Oldest commit that introduced this version string = the cycle-open commit.
|
||
const hits = runGit(["log", "--format=%H", "-S", `"version": "${version}"`, "--", "package.json"]);
|
||
openCommit = hits ? hits.split("\n").filter(Boolean).pop() : "";
|
||
} catch {
|
||
openCommit = "";
|
||
}
|
||
if (openCommit) return { base: openCommit, source: "cycle-open" };
|
||
|
||
let tag = "";
|
||
try {
|
||
tag = runGit(["describe", "--tags", "--abbrev=0"]);
|
||
} catch {
|
||
tag = "";
|
||
}
|
||
process.stderr.write(
|
||
`[list-uncovered-commits] WARNING: could not find the commit that opened v${version} ` +
|
||
`(searched package.json for the version string). Falling back to ` +
|
||
`${tag || "the repository root"} — because releases squash-merge, that range re-lists ` +
|
||
`previous cycles and the report below will contain noise.\n`
|
||
);
|
||
return { base: tag, source: "tag-fallback" };
|
||
}
|
||
|
||
export function refsOf(subject) {
|
||
return [...subject.matchAll(/#(\d+)/g)].map((m) => Number(m[1]));
|
||
}
|
||
|
||
export function typeOf(subject) {
|
||
const m = subject.match(/^([a-z]+)(\(|:|!)/);
|
||
return m ? m[1] : "other";
|
||
}
|
||
|
||
/**
|
||
* @param {{hash:string, subject:string}[]} commits
|
||
* @param {Set<number>} changelogRefs every #N present in the CHANGELOG scan window
|
||
* @returns {{covered:number, uncovered:{hash,subject,refs,type,rollup}[]}}
|
||
*/
|
||
export function computeUncovered(commits, changelogRefs) {
|
||
const uncovered = [];
|
||
let covered = 0;
|
||
for (const c of commits) {
|
||
const refs = refsOf(c.subject);
|
||
const isCovered = refs.length > 0 && refs.some((r) => changelogRefs.has(r));
|
||
if (isCovered) {
|
||
covered++;
|
||
} else {
|
||
const type = typeOf(c.subject);
|
||
uncovered.push({ ...c, refs, type, rollup: ROLLUP_TYPES.has(type) });
|
||
}
|
||
}
|
||
return { covered, uncovered };
|
||
}
|
||
|
||
// Subdirectories that hold changelog.d fragments (fragments-first, #6783).
|
||
const FRAGMENT_DIRS = ["features", "fixes", "maintenance"];
|
||
|
||
/**
|
||
* Read the leading `<N>-` PR/issue number from a changelog.d fragment filename.
|
||
* Some fragments (e.g. 6708, 6709) carry NO `#N` in their body, so the filename is the only
|
||
* place the PR number appears — it must still count as covering that PR.
|
||
* @param {string} filename bare name or a path ending in the fragment file
|
||
* @returns {number|null}
|
||
*/
|
||
export function fragmentFilenameRef(filename) {
|
||
const base = String(filename).replace(/^.*[\\/]/, "");
|
||
const m = base.match(/^(\d+)-/);
|
||
return m ? Number(m[1]) : null;
|
||
}
|
||
|
||
/**
|
||
* Collect every ref "covered" by changelog.d fragments: the leading `<N>-` of each fragment
|
||
* filename PLUS every `#N` inside its body.
|
||
* @param {{name:string, body:string}[]} fragments
|
||
* @returns {Set<number>}
|
||
*/
|
||
export function fragmentRefs(fragments) {
|
||
const refs = new Set();
|
||
for (const f of fragments || []) {
|
||
const fromName = fragmentFilenameRef(f.name);
|
||
if (fromName != null) refs.add(fromName);
|
||
for (const m of String(f.body || "").matchAll(/#(\d+)/g)) refs.add(Number(m[1]));
|
||
}
|
||
return refs;
|
||
}
|
||
|
||
/**
|
||
* Union of the CHANGELOG scan window refs and the changelog.d fragment refs. Since fragments-first
|
||
* (#6783) a merged PR's changelog entry usually lives in a fragment and is only folded into
|
||
* CHANGELOG.md at release time, so scanning CHANGELOG.md alone reports fragment-covered commits as
|
||
* uncovered (#6857).
|
||
* @param {string} changelog
|
||
* @param {string} version
|
||
* @param {{name:string, body:string}[]} fragments
|
||
* @returns {Set<number>}
|
||
*/
|
||
export function collectChangelogRefs(changelog, version, fragments) {
|
||
const refs = changelogRefWindow(changelog, version);
|
||
for (const r of fragmentRefs(fragments)) refs.add(r);
|
||
return refs;
|
||
}
|
||
|
||
/** Read all changelog.d fragment files (name + body) from disk under `root`. */
|
||
export function readChangelogFragments(root) {
|
||
const out = [];
|
||
for (const sub of FRAGMENT_DIRS) {
|
||
const dir = path.join(root, "changelog.d", sub);
|
||
if (!fs.existsSync(dir)) continue;
|
||
for (const name of fs.readdirSync(dir)) {
|
||
if (!name.endsWith(".md") || name === "README.md") continue;
|
||
out.push({ name, body: fs.readFileSync(path.join(dir, name), "utf8") });
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Read every #N in the version's CHANGELOG section + the [Unreleased] section. */
|
||
export function changelogRefWindow(changelog, version) {
|
||
const esc = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
// From [Unreleased] up to (but excluding) the version-after-this one.
|
||
const startRe = /^## \[Unreleased\]/m;
|
||
const s = changelog.match(startRe);
|
||
const from = s ? s.index : 0;
|
||
// find the header AFTER the target version
|
||
const verRe = new RegExp(`^## \\[${esc}\\]`, "m");
|
||
const vm = changelog.slice(from).match(verRe);
|
||
const afterVersionStart = vm ? from + vm.index + vm[0].length : from;
|
||
const rest = changelog.slice(afterVersionStart);
|
||
const nextIdx = rest.search(/\n## \[/);
|
||
const to = nextIdx === -1 ? changelog.length : afterVersionStart + nextIdx;
|
||
const window = changelog.slice(from, to);
|
||
return new Set([...window.matchAll(/#(\d+)/g)].map((m) => Number(m[1])));
|
||
}
|
||
|
||
function main(argv) {
|
||
const jsonOut = argv.includes("--json");
|
||
const version = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version;
|
||
const { base, source } = resolveCycleBase(version);
|
||
const lastTag = base ? git(["rev-parse", "--short", base]) : base;
|
||
const log = git(["log", "--no-merges", `${base}..HEAD`, "--pretty=format:%h%x09%s"]);
|
||
const commits = log
|
||
? log.split("\n").map((l) => {
|
||
const [hash, subject] = l.split("\t");
|
||
return { hash, subject };
|
||
})
|
||
: [];
|
||
const changelog = fs.readFileSync(path.join(ROOT, "CHANGELOG.md"), "utf8");
|
||
const fragments = readChangelogFragments(ROOT);
|
||
const refs = collectChangelogRefs(changelog, version, fragments);
|
||
const { covered, uncovered } = computeUncovered(commits, refs);
|
||
|
||
if (jsonOut) {
|
||
process.stdout.write(
|
||
JSON.stringify(
|
||
{ version, base: lastTag, baseSource: source, total: commits.length, covered, uncovered },
|
||
null,
|
||
2
|
||
) + "\n"
|
||
);
|
||
return;
|
||
}
|
||
const bulletsWorthy = uncovered.filter((c) => !c.rollup);
|
||
const rollupCandidates = uncovered.filter((c) => c.rollup);
|
||
process.stdout.write(
|
||
`# Uncovered-commit reconciliation — v${version} (${lastTag}..HEAD, base from ${source})\n\n`
|
||
);
|
||
process.stdout.write(
|
||
`Commits: ${commits.length} · covered: ${covered} · uncovered: ${uncovered.length}\n\n`
|
||
);
|
||
process.stdout.write(
|
||
`## Needs a bullet (feat/fix/other — user-facing) — ${bulletsWorthy.length}\n`
|
||
);
|
||
for (const c of bulletsWorthy) process.stdout.write(`- ${c.hash} ${c.subject}\n`);
|
||
process.stdout.write(
|
||
`\n## Rollup candidates (chore/ci/test/refactor/docs) — ${rollupCandidates.length}\n`
|
||
);
|
||
for (const c of rollupCandidates) process.stdout.write(`- ${c.hash} ${c.subject}\n`);
|
||
process.stdout.write(
|
||
`\n> Advisory. Add a bullet for each user-facing item; consolidate rollup candidates into a few Maintenance bullets (list their PR numbers).\n`
|
||
);
|
||
}
|
||
|
||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||
main(process.argv.slice(2));
|
||
}
|