compare-pr-version.ts
151 lines · scripts/compare-pr-version.ts at 3f79d81
1#!/usr/bin/env -S bun --no-env-file --config=/dev/null
2// compare-pr-version — CI gate helper. Validates the PR's branch VERSION
3// against the queue of other open PRs' claimed versions. Exits 0 (pass)
4// or 1 (confirmed collision).
5//
6// Input:
7// argv[2] — path to next.json (the util's JSON output)
8// argv[3] — optional PR number for log lines
9//
10// Design note: fail-open on util error. A gstack bug must never freeze the
11// merge queue. The gate enforces ONE rule: this PR must not claim the same
12// version as another open PR. Lower-than-the-util's-suggestion is fine if
13// the slot is unclaimed — that preserves monotonic version ordering on main
14// when this PR lands ahead of higher-numbered queued PRs. The util's output
15// is informational (the *recommended* slot for fresh /ship runs); the gate
16// only blocks actual collisions.
18import { readFileSync } from "node:fs";
19import { classifyTrainHead } from "../lib/version-gate-train";
21const [, , jsonPath, prNumber] = process.argv;
22if (!jsonPath) {
23 console.error("Usage: compare-pr-version <next.json> [pr-number]");
24 process.exit(2);
25}
27let parsed: any;
28try {
29 parsed = JSON.parse(readFileSync(jsonPath, "utf8"));
30} catch (e) {
31 console.log("::warning::could not parse util output; failing open");
32 process.exit(0);
33}
35const tag = prNumber ? `PR #${prNumber}` : "this PR";
36const prVersionEnv = (process.env.PR_VERSION ?? "").trim();
38// Merge trains: when the base's ship policy declares a `train` block, a READY
39// head is unstamped and the lane stamps at its slot (docs/ship-policy.md#run-a-merge-train).
40// The workflow passes BASE_REF and HEAD_SHA with the PR head checked out as cwd.
41if (process.env.BASE_REF && process.env.HEAD_SHA) {
42 const train = classifyTrainHead({ repoRoot: process.cwd(), baseRef: process.env.BASE_REF, head: process.env.HEAD_SHA, author: process.env.PR_AUTHOR });
43 if (train.kind === "unstamped") {
44 if (train.entry === "kept") {
45 console.log(`✓ ${tag} keeps its merge base's VERSION and package.json version, as the base's ship policy (bump never) requires.`);
46 process.exit(0);
47 }
48 const entry = train.entry === "fragment" ? `its entry is ${train.fragments} CHANGELOG fragment(s)` : train.entry === "none-bot" ? "it is a bot PR with no entry (the merge step writes one from its title)" : "the CHANGELOG change is confined to [Unreleased]";
49 console.log(`✓ ${tag} is unstamped under the base's ship policy: VERSION and package.json match its merge base, any changed release output is what the release tool generates at the merge base's VERSION, and ${entry}. The merge step stamps it.`);
50 process.exit(0);
51 }
52 if (train.kind === "invalid") {
53 console.log(`::error::${tag}: ${train.reason}.`);
54 process.exit(1);
55 }
56 if (train.kind === "stamped" && prVersionEnv !== train.expected) {
57 console.log(`::error::${tag} claims v${prVersionEnv}, but under the base's ship policy a stamped head must carry v${train.expected} (base + the effective bump, what gstack-restamp --next allocates at the merge step). Leave VERSION, package.json and the release outputs at the base's values until the merge step stamps them.`);
58 process.exit(1);
59 }
60 if (train.kind === "stamped" && train.fragments) {
61 console.log(`✓ ${tag} carries v${train.expected}, what the merge step allocates under changelog_fragments; the land lock serializes allocation, so open claims do not apply.`);
62 process.exit(0);
63 }
64}
66if (parsed.offline === true) {
67 console.log("::warning::workspace-aware-ship util offline; failing open (no collision check performed)");
68 console.log(`::notice::If you merge this PR and a queued PR landed ahead, CHANGELOG may need manual reconciliation.`);
69 process.exit(0);
70}
72// PR_VERSION is supplied via env (set by the workflow from `cat VERSION`).
73const prVersion = (process.env.PR_VERSION ?? "").trim();
74const nextSlot = parsed.version;
76if (!prVersion) {
77 console.log("::warning::PR_VERSION not set; failing open");
78 process.exit(0);
79}
81// Parse versions for comparison.
82function parseV(s: string): number[] | null {
83 const m = s.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
84 return m ? [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])] : null;
85}
86function cmp(a: number[], b: number[]): number {
87 for (let i = 0; i < 4; i++) if (a[i] !== b[i]) return a[i] - b[i];
88 return 0;
89}
90const pPR = parseV(prVersion);
91const pNext = parseV(nextSlot);
92if (!pPR || !pNext) {
93 console.log(`::warning::malformed version string (PR=${prVersion}, next=${nextSlot}); failing open`);
94 process.exit(0);
95}
97const claimed = (parsed.claimed ?? []) as Array<{ pr: number; branch: string; version: string; url?: string }>;
99// Emit a GitHub step summary (always helpful, even on pass).
100const claimedList = claimed
101 .map((c) => ` #${c.pr} ${c.branch} → v${c.version}`)
102 .join("\n");
104console.log(`::group::Version gate (${tag})`);
105console.log(` PR VERSION: v${prVersion}`);
106console.log(` Suggested: v${nextSlot} (util's next-slot recommendation)`);
107console.log(` Queue (${claimed.length} open PRs claiming versions):`);
108if (claimedList) console.log(claimedList);
109console.log("::endgroup::");
111// Dependabot never edits VERSION, so hard rule 1 would fail every dependency
112// PR it opens. Exempt it only when the author is dependabot and every changed
113// file is a dependency manifest, a lockfile or the dependabot config. The
114// workflow passes the author and the file list; an empty list (the lookup
115// failed) keeps the rule.
116const DEPENDENCY_FILE = /(?:^|\/)(?:package\.json|bun\.lockb?)$|^\.github\/dependabot\.yml$/;
117const changedFiles = (process.env.PR_CHANGED_FILES ?? "").split("\n").map((f) => f.trim()).filter(Boolean);
118const dependabotManifestOnly =
119 process.env.PR_AUTHOR === "dependabot[bot]" && changedFiles.length > 0 && changedFiles.every((f) => DEPENDENCY_FILE.test(f));
121// Hard rule 1: this PR's VERSION must be strictly greater than the base
122// version, otherwise we're not actually bumping.
123const pBase = parseV((parsed.base_version ?? "").trim());
124if (pBase && cmp(pPR, pBase) <= 0) {
125 if (dependabotManifestOnly) {
126 console.log(`::notice::${tag} is a dependabot dependency update (${changedFiles.join(", ")}); VERSION bump not required.`);
127 process.exit(0);
128 }
129 console.log(`::error::VERSION not bumped: ${tag} claims v${prVersion} but base is v${parsed.base_version}.`);
130 process.exit(1);
131}
133// Hard rule 2: no collision with another open PR's claimed VERSION.
134const collision = claimed.find((c) => c.version.trim() === prVersion);
135if (collision) {
136 console.log(`::error::VERSION collision: ${tag} claims v${prVersion} but #${collision.pr} (${collision.branch}) already claims the same slot.`);
137 console.log(`::error::Rerun /ship to pick a different slot, or coordinate with #${collision.pr} on landing order.`);
138 process.exit(1);
139}
141// Optional informational note: PR version is below the util's suggested next
142// slot. This is allowed — the suggested slot is a recommendation for /ship's
143// next run, but landing at a lower-but-unclaimed slot first preserves
144// monotonic ordering on main when this PR merges ahead of higher-numbered
145// queued PRs.
146if (cmp(pPR, pNext) < 0) {
147 console.log(`::notice::${tag} claims v${prVersion}, below util's suggestion v${nextSlot}. Slot is unclaimed; gate passes. If this PR lands ahead of queued PRs at higher slots, version ordering on main remains monotonic.`);
148}
150console.log(`✓ ${tag} claims v${prVersion} — slot is free.`);
151process.exit(0);