Files
fusion/scripts/__tests__/check-pre-json-anchor.test.mjs
gsxdsm 027faaa09f ci(release): fail PRs that regress the beta cycle in .changeset/pre.json (#3486)
## 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>
2026-08-18 22:10:59 -07:00

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/);
});