## Problem PR #3472 resolved a `.changeset/pre.json` rebase conflict against a copy predating the v0.76.0 stable: | | at `v0.77.0-beta.1` | after #3472 | |---|---|---| | `initialVersions["@runfusion/fusion"]` | `0.76.0` | `0.75.1` | | consumed ledger | 67 entries | 158 (the pre-0.76.0 cycle's) | Nothing failed at PR time. Days later `pnpm release` saw the cycle anchored below the shipped `v0.76.0`, fired its stale-cycle re-anchor (`pre exit` → rewrite all 36 `package.json` → `pre enter`), and proposed **`0.77.0-beta.0`** — below the already-published `0.77.0-beta.1`. The re-anchor guard exists to stop a beta numbering under a stable; fed a stale anchor it caused exactly that. `pre.json` is generated by changesets, hand-edited by nobody, and conflicts in nearly every long-lived branch — so a wrong resolution is invisible until release day. This moves the failure to the PR that causes it. ## The check `scripts/check-pre-json-anchor.mjs`, three invariants: - **`anchor-below-stable`** — `initialVersions` must not sit below the newest `v*` stable tag. This is the exact predicate `evaluateBetaCycleAnchor` keys on in `release.mjs`, so green here means the release will *not* re-anchor. - **`ledger-regression`** — the consumed ledger must stay a **superset** of the last `chore(release):` commit's. Deliberately not a count test: #3472's ledger *grew* 67 → 158 while dropping all 67 real entries, so a size comparison would have passed it. - **`dangling-ledger-entry`** — every consumed entry keeps its `.changeset/*.md`, which pre-mode needs to aggregate notes into the eventual stable release. Skips cleanly outside pre-mode (the stable track deletes `pre.json`). ## Wiring Added to the **Lint** job and `pretest`. Job names are unchanged, so no branch-protection update is needed. The Lint checkout takes `fetch-depth: 200` + `fetch-tags` rather than a full 486MB clone — releases land every few days, so that always reaches a baseline; out of range the ledger rule reports `SKIPPED` rather than passing vacuously, and the two local rules still run. ## Verification - Reproducing #3472's exact `pre.json` in the tree → **exit 1** on all three rules, with the 67 dropped entries named. - Clean `main` → exit 0. - 11 unit tests (`scripts/__tests__/check-pre-json-anchor.test.mjs`), including an explicit assertion that the ledger *grew* in the regression case. - `eslint` clean; workflow YAML parses; job names still `Lint, Typecheck, Build, Gate`. No changeset: CI config only, no `@runfusion/fusion` behavior change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added automated validation for beta release-cycle metadata. * Checks anchor versions, consumed changeset records, and corresponding changeset files. * Provides clear success or error messages and skips checks when release history is unavailable or not applicable. * **Chores** * Pull request checks now run the beta-cycle validation automatically. * Added comprehensive coverage for valid, invalid, and skipped validation scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
146 lines
5.1 KiB
JavaScript
146 lines
5.1 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { evaluatePreJsonInvariants, ANCHOR_PACKAGE } from "../check-pre-json-anchor.mjs";
|
|
|
|
const pre = (anchor, changesets = []) => ({
|
|
mode: "pre",
|
|
tag: "beta",
|
|
initialVersions: { [ANCHOR_PACKAGE]: anchor },
|
|
changesets,
|
|
});
|
|
const rules = (result) => result.violations.map((v) => v.rule).sort();
|
|
|
|
test("healthy cycle anchored on the shipped stable passes", () => {
|
|
const result = evaluatePreJsonInvariants({
|
|
preState: pre("0.76.0", ["a", "b"]),
|
|
latestStable: "0.76.0",
|
|
changesetFiles: ["a", "b", "c"],
|
|
baselinePreState: pre("0.76.0", ["a", "b"]),
|
|
});
|
|
assert.deepEqual(result.violations, []);
|
|
assert.deepEqual(result.skipped, []);
|
|
});
|
|
|
|
test("a cycle that consumed more changesets since the last release still passes", () => {
|
|
const result = evaluatePreJsonInvariants({
|
|
preState: pre("0.76.0", ["a", "b", "c"]),
|
|
latestStable: "0.76.0",
|
|
changesetFiles: ["a", "b", "c"],
|
|
baselinePreState: pre("0.76.0", ["a", "b"]),
|
|
});
|
|
assert.deepEqual(result.violations, []);
|
|
});
|
|
|
|
/*
|
|
The PR #3472 shape: pre.json reverted to the pre-v0.76.0 cycle. The anchor fell
|
|
below the shipped stable AND the ledger swapped an older, LARGER list in for the
|
|
real entries. Both invariants must fire; the size test that a naive check would
|
|
use is asserted to be useless here.
|
|
*/
|
|
test("reverting pre.json to a pre-stable cycle fires anchor + ledger (the #3472 regression)", () => {
|
|
const baseline = pre("0.76.0", ["real-1", "real-2"]);
|
|
const reverted = pre("0.75.1", ["old-1", "old-2", "old-3", "old-4"]);
|
|
const result = evaluatePreJsonInvariants({
|
|
preState: reverted,
|
|
latestStable: "0.76.0",
|
|
changesetFiles: ["real-1", "real-2", "old-1", "old-2", "old-3", "old-4"],
|
|
baselinePreState: baseline,
|
|
});
|
|
assert.deepEqual(rules(result), ["anchor-below-stable", "ledger-regression"]);
|
|
// The ledger GREW while dropping every real entry — a count check passes it.
|
|
assert.ok(reverted.changesets.length > baseline.changesets.length);
|
|
const dropped = result.violations.find((v) => v.rule === "ledger-regression");
|
|
assert.match(dropped.message, /real-1/);
|
|
assert.match(dropped.message, /real-2/);
|
|
});
|
|
|
|
test("anchor below stable is reported even when the ledger is intact", () => {
|
|
const result = evaluatePreJsonInvariants({
|
|
preState: pre("0.75.1", ["a"]),
|
|
latestStable: "0.76.0",
|
|
changesetFiles: ["a"],
|
|
baselinePreState: pre("0.75.1", ["a"]),
|
|
});
|
|
assert.deepEqual(rules(result), ["anchor-below-stable"]);
|
|
});
|
|
|
|
test("anchor equal to the shipped stable is fine (release.mjs just re-anchored)", () => {
|
|
const result = evaluatePreJsonInvariants({
|
|
preState: pre("0.76.0", []),
|
|
latestStable: "0.76.0",
|
|
changesetFiles: [],
|
|
baselinePreState: pre("0.76.0", []),
|
|
});
|
|
assert.deepEqual(result.violations, []);
|
|
});
|
|
|
|
test("prerelease anchors compare below their own stable", () => {
|
|
const result = evaluatePreJsonInvariants({
|
|
preState: pre("0.76.0-beta.3", []),
|
|
latestStable: "0.76.0",
|
|
changesetFiles: [],
|
|
baselinePreState: null,
|
|
});
|
|
assert.deepEqual(rules(result), ["anchor-below-stable"]);
|
|
});
|
|
|
|
test("a dropped .changeset/*.md for a consumed entry is a dangling ledger entry", () => {
|
|
const result = evaluatePreJsonInvariants({
|
|
preState: pre("0.76.0", ["a", "gone"]),
|
|
latestStable: "0.76.0",
|
|
changesetFiles: ["a"],
|
|
baselinePreState: pre("0.76.0", ["a", "gone"]),
|
|
});
|
|
assert.deepEqual(rules(result), ["dangling-ledger-entry"]);
|
|
assert.match(result.violations[0].message, /gone\.md/);
|
|
});
|
|
|
|
test("missing initialVersions anchor is a violation, not a silent pass", () => {
|
|
const result = evaluatePreJsonInvariants({
|
|
preState: { mode: "pre", tag: "beta", initialVersions: {}, changesets: [] },
|
|
latestStable: "0.76.0",
|
|
changesetFiles: [],
|
|
baselinePreState: null,
|
|
});
|
|
assert.deepEqual(rules(result), ["anchor-below-stable"]);
|
|
});
|
|
|
|
test("no pre.json (stable track, pre exit ran) checks nothing", () => {
|
|
for (const preState of [null, { mode: "exit", initialVersions: {}, changesets: [] }]) {
|
|
const result = evaluatePreJsonInvariants({
|
|
preState,
|
|
latestStable: "0.76.0",
|
|
changesetFiles: [],
|
|
baselinePreState: pre("0.76.0", ["a"]),
|
|
});
|
|
assert.deepEqual(result.violations, []);
|
|
}
|
|
});
|
|
|
|
test("no stable tag yet (fresh repo) skips only the anchor comparison", () => {
|
|
const result = evaluatePreJsonInvariants({
|
|
preState: pre("0.1.0", ["a"]),
|
|
latestStable: null,
|
|
changesetFiles: ["a"],
|
|
baselinePreState: pre("0.1.0", ["a"]),
|
|
});
|
|
assert.deepEqual(result.violations, []);
|
|
});
|
|
|
|
/*
|
|
A shallow clone with no reachable `chore(release):` commit must SKIP the ledger
|
|
invariant loudly rather than pass it vacuously — the local invariants still run.
|
|
*/
|
|
test("absent baseline skips the ledger rule but still enforces the local ones", () => {
|
|
const result = evaluatePreJsonInvariants({
|
|
preState: pre("0.75.1", ["a", "gone"]),
|
|
latestStable: "0.76.0",
|
|
changesetFiles: ["a"],
|
|
baselinePreState: null,
|
|
});
|
|
assert.deepEqual(rules(result), ["anchor-below-stable", "dangling-ledger-entry"]);
|
|
assert.equal(result.skipped.length, 1);
|
|
assert.match(result.skipped[0], /ledger-regression/);
|
|
});
|