feat: accept 3-digit semver + package.json version sources (#2501)

Two version-source shapes failed CLOSED in a way that silently disabled
/ship's queue-collision check:

1. A --version-path / .gstack/version-path target that is a package.json
   was read as raw text: the whitespace strip turned the JSON into
   '{"name":"frontend",... which parseVersion rejected, so every read —
   local, `git show`, and rival PRs' claims through the GitHub/GitLab
   Contents APIs — fell back to 0.0.0.0 and competing claims were dropped
   as "malformed".
2. parseVersion required exactly four components, so gstack-next-version
   exited 2 on EVERY invocation in a 3-digit repo. That CLI IS the
   queue-collision check; /ship then took its documented offline path of
   naive local arithmetic, two branches cut from the same base picked the
   same version, and git merged the duplicate without a conflict.

New lib/version-source.ts holds the shared semantics so both CLIs agree
by construction: parseVersion accepts 3- or 4-digit (3 pads the micro
slot for uniform comparison), versionWidth/fmtVersion keep a 3-digit repo
3-digit through bumping and formatting, micro coerces to patch on 3-digit
repos (with a warning in the output), and extractVersion reads a .json
version-path as JSON (.version) from any byte source. gstack-version-bump
treats a package.json version-path as that repo's single source of truth
(written in place, DRIFT_* states can't arise — no second file to drift
from). Detection is by shape, not new configuration.

Scope per the wave plan's version-tooling end-state spec (decision 11,
ENG-OV1): this is the READING capability + 3-digit acceptance ONLY.
gstack's own VERSION file stays the 4-digit source of truth; nothing here
flips authority to package.json. The PR's bundled fix for the
.gstack/version-path pin being ignored by classify's base read lands
separately (#2462) — these tests drive the JSON version-path through the
explicit --version-path flag.

Re-derived from PR #2501 by @YiftahR (73 tests pass across
test/gstack-version-bump.test.ts, test/gstack-next-version.test.ts,
test/ship-version-sync.test.ts).

Fixes #2501

Co-authored-by: YR <work.yiftah.rottem@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-16 10:02:31 -07:00
co-authored by YR Claude Fable 5
parent c7faef885b
commit d7ab20ac06
5 changed files with 359 additions and 59 deletions
+50 -47
View File
@@ -19,6 +19,13 @@
// committed so all collaborators benefit)
// 3. "VERSION" at the repo root (default, backward-compatible)
//
// The pinned path may be a package.json (any depth) rather than a plain-text
// VERSION file: a path ending in .json is read as JSON and its .version taken.
// 3-digit semver is accepted as well as 4-digit, and stays 3-digit through
// bumping. See lib/version-source.ts for why both mattered — each used to fail
// closed, which silently disabled the queue-collision check this CLI exists to
// provide (#2501).
//
// Exit codes:
// 0 — emitted JSON successfully (may include "offline":true or "host":"unknown")
// 2 — invalid arguments
@@ -28,9 +35,18 @@ import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
type Bump = "major" | "minor" | "patch" | "micro";
type Version = [number, number, number, number];
import {
parseVersion,
versionWidth,
fmtVersion,
bumpVersion,
cmpVersion,
bumpWasCoerced,
extractVersion,
type Bump,
type Version,
type VersionWidth,
} from "../lib/version-source";
type ClaimedPR = {
pr: number;
@@ -66,48 +82,20 @@ type Output = {
const ACTIVE_SIBLING_MAX_AGE_S = 24 * 60 * 60;
const GH_API_CONCURRENCY = 10;
function parseVersion(s: string): Version | null {
const m = s.trim().match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
if (!m) return null;
return [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
}
function fmtVersion(v: Version): string {
return v.join(".");
}
function bumpVersion(v: Version, level: Bump): Version {
switch (level) {
case "major":
return [v[0] + 1, 0, 0, 0];
case "minor":
return [v[0], v[1] + 1, 0, 0];
case "patch":
return [v[0], v[1], v[2] + 1, 0];
case "micro":
return [v[0], v[1], v[2], v[3] + 1];
}
}
function cmpVersion(a: Version, b: Version): number {
for (let i = 0; i < 4; i++) {
if (a[i] !== b[i]) return a[i] - b[i];
}
return 0;
}
// Collision resolution: bump past the highest claimed within the same level.
// Semantics: if my bump is MINOR and the queue claims 1.7.0.0, I advance to
// 1.8.0.0 (still a MINOR relative to main). Preserves ship-time intent.
function pickNextSlot(base: Version, claimed: Version[], level: Bump): { version: Version; reason: string } {
let candidate = bumpVersion(base, level);
// `width` keeps a 3-digit repo 3-digit (see lib/version-source.ts); it
// defaults to 4 so existing callers and tests are unaffected.
function pickNextSlot(base: Version, claimed: Version[], level: Bump, width: VersionWidth = 4): { version: Version; reason: string } {
let candidate = bumpVersion(base, level, width);
const sortedClaimed = [...claimed].sort(cmpVersion);
const highest = sortedClaimed[sortedClaimed.length - 1];
if (highest && cmpVersion(highest, base) > 0) {
// Queue already advanced past base; bump past the highest claim.
const bumpedPastHighest = bumpVersion(highest, level);
const bumpedPastHighest = bumpVersion(highest, level, width);
if (cmpVersion(bumpedPastHighest, candidate) > 0) {
return { version: bumpedPastHighest, reason: `bumped past claimed ${fmtVersion(highest)}` };
return { version: bumpedPastHighest, reason: `bumped past claimed ${fmtVersion(highest, width)}` };
}
}
return { version: candidate, reason: "no collision; clean bump from base" };
@@ -167,7 +155,12 @@ function readBaseVersion(base: string, versionPath: string, warnings: string[]):
warnings.push(`could not read ${versionPath} at origin/${base}; assuming 0.0.0.0`);
return "0.0.0.0";
}
return r.stdout.trim();
const v = extractVersion(r.stdout, versionPath);
if (!v) {
warnings.push(`${versionPath} at origin/${base} has no readable version; assuming 0.0.0.0`);
return "0.0.0.0";
}
return v;
}
async function fetchGithubClaimed(base: string, versionPath: string, excludePR: number | null, warnings: string[]): Promise<{ claimed: ClaimedPR[]; offline: boolean }> {
@@ -233,7 +226,7 @@ async function fetchGithubClaimed(base: string, versionPath: string, excludePR:
}
let versionStr: string;
try {
versionStr = Buffer.from(content.stdout.trim(), "base64").toString("utf8").trim();
versionStr = extractVersion(Buffer.from(content.stdout.trim(), "base64").toString("utf8"), versionPath);
} catch {
warnings.push(`PR #${pr.number}: VERSION is not valid base64`);
continue;
@@ -290,7 +283,7 @@ async function fetchGitlabClaimed(base: string, versionPath: string, excludePR:
}
try {
const j = JSON.parse(content.stdout);
const versionStr = Buffer.from(j.content, "base64").toString("utf8").trim();
const versionStr = extractVersion(Buffer.from(j.content, "base64").toString("utf8"), versionPath);
if (!parseVersion(versionStr)) {
warnings.push(`MR !${mr.iid}: VERSION malformed (${versionStr})`);
continue;
@@ -349,7 +342,7 @@ function scanSiblings(root: string | null, versionPath: string, claimed: Claimed
if (!existsSync(versionFile)) continue;
let version: string;
try {
version = readFileSync(versionFile, "utf8").trim();
version = extractVersion(readFileSync(versionFile, "utf8"), versionPath);
if (!parseVersion(version)) continue;
} catch {
continue;
@@ -469,6 +462,13 @@ async function main() {
console.error(`Error: could not parse base version '${baseVersion}'`);
process.exit(2);
}
// The repo's own width governs everything downstream: a 3-digit repo must
// not be handed a 4-digit slot, or /ship writes a version the repo's tooling
// can't read back (#2501).
const width = versionWidth(baseVersion);
if (bumpWasCoerced(args.bump, width)) {
warnings.push(`--bump micro has no component to move in a ${width}-digit version; treated as patch`);
}
const excludePR = args.excludePR ?? autoDetectExcludePR();
if (excludePR !== null && args.excludePR === null) {
@@ -495,7 +495,7 @@ async function main() {
.map((c) => parseVersion(c.version))
.filter((v): v is Version => v !== null);
const { version: picked, reason } = pickNextSlot(baseParsed, claimedVersions, args.bump);
const { version: picked, reason } = pickNextSlot(baseParsed, claimedVersions, args.bump, width);
const workspaceRoot = resolveWorkspaceRoot(args.workspaceRoot);
const siblings = markActiveSiblings(scanSiblings(workspaceRoot, versionPath, claimed, warnings), baseParsed);
@@ -510,12 +510,12 @@ async function main() {
.filter((v) => cmpVersion(v, finalVersion) >= 0);
if (activeAhead.length) {
const highest = activeAhead.sort(cmpVersion)[activeAhead.length - 1];
finalVersion = bumpVersion(highest, args.bump);
finalReason = `bumped past active sibling ${fmtVersion(highest)}`;
finalVersion = bumpVersion(highest, args.bump, width);
finalReason = `bumped past active sibling ${fmtVersion(highest, width)}`;
}
const out: Output = {
version: fmtVersion(finalVersion),
version: fmtVersion(finalVersion, width),
current_version: args.current || baseVersion,
base_version: baseVersion,
version_path: versionPath,
@@ -531,8 +531,11 @@ async function main() {
process.stdout.write(JSON.stringify(out, null, 2) + "\n");
}
// Pure-function exports for testing
export { parseVersion, fmtVersion, bumpVersion, cmpVersion, pickNextSlot, markActiveSiblings, resolveVersionPath };
// Pure-function exports for testing. The version primitives are re-exported
// from lib/version-source so existing importers of this module keep working
// unchanged.
export { parseVersion, fmtVersion, bumpVersion, cmpVersion, versionWidth, extractVersion };
export { pickNextSlot, markActiveSiblings, resolveVersionPath };
// Only run main() when invoked as a script, not when imported by tests.
if (import.meta.main) {
+51 -8
View File
@@ -37,8 +37,13 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { join } from "node:path";
import { extractVersion, isJsonVersionPath, setVersionInJson } from "../lib/version-source";
const VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/;
// 3- or 4-digit (#2501). gstack's own VERSION stays 4-digit MAJOR.MINOR.PATCH.
// MICRO and stays the source of truth, but a repo whose pinned version source
// is a package.json holds plain 3-digit semver, and rejecting it here meant
// /ship could not write a version at all in such a repo. See lib/version-source.ts.
const VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$/;
const DEFAULT = "0.0.0.0";
type State = "FRESH" | "ALREADY_BUMPED" | "DRIFT_STALE_PKG" | "DRIFT_UNEXPECTED";
@@ -64,9 +69,11 @@ function resolveVersionPath(cwd: string, explicit?: string): string {
return join(cwd, "VERSION");
}
function readVersionFile(p: string): string {
function readVersionFile(p: string, versionRel = "VERSION"): string {
try {
const v = readFileSync(p, "utf-8").replace(/[\r\n\s]/g, "");
// extractVersion (#2501): a .json version-path is read as JSON (.version),
// not whitespace-stripped raw text that turns a package.json into garbage.
const v = extractVersion(readFileSync(p, "utf-8"), versionRel);
return v || DEFAULT;
} catch {
return DEFAULT;
@@ -110,8 +117,7 @@ function baseVersion(cwd: string, base: string, versionRel: string): string {
}
try {
const out = execFileSync("git", ["show", `origin/${base}:${versionRel}`], { cwd }).toString();
const v = out.replace(/[\r\n\s]/g, "");
return v || DEFAULT;
return extractVersion(out, versionRel) || DEFAULT;
} catch {
// VERSION absent on base (new repo / new file) → treat as 0.0.0.0.
return DEFAULT;
@@ -135,9 +141,16 @@ function cmdClassify(args: string[], cwd: string): void {
if (!base) fail("classify requires --base <branch>", 2);
const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path"));
const versionRel = argVal(args, "--version-path") ?? "VERSION";
const current = readVersionFile(versionPath);
const current = readVersionFile(versionPath, versionRel);
const baseV = baseVersion(cwd, base!, versionRel);
const pkg = readPkgVersion(cwd);
// When the version-path IS a package.json (#2501), that file is the single
// source of truth and the "VERSION vs package.json" drift states cannot
// arise — they are the same file. Reporting it as its own pkg keeps DRIFT_*
// out of the classification instead of inventing a disagreement between a
// file and itself.
const pkg = isJsonVersionPath(versionRel)
? { exists: existsSync(versionPath), version: current === DEFAULT ? "" : current }
: readPkgVersion(cwd);
const state = classifyState(current, baseV, pkg.exists, pkg.version);
process.stdout.write(
JSON.stringify({
@@ -160,6 +173,26 @@ function cmdWrite(args: string[], cwd: string): void {
fail(`NEW_VERSION (${version}) does not match MAJOR.MINOR.PATCH.MICRO. Aborting.`, 2);
}
const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path"));
const versionRel = argVal(args, "--version-path") ?? "VERSION";
// A package.json version-path (#2501) is written in place, keeping the rest
// of the file intact — and it is the ONLY file written. Also syncing a root
// package.json here would be a guess about which of two JSON files the repo
// actually publishes from; in a monorepo whose truth is frontend/package.json
// the root one either doesn't exist or isn't the version users see.
if (isJsonVersionPath(versionRel)) {
if (!existsSync(versionPath)) {
fail(`write: ${versionRel} does not exist. Check --version-path / .gstack/version-path.`, 2);
}
try {
writeFileSync(versionPath, setVersionInJson(readFileSync(versionPath, "utf-8"), version!));
} catch {
fail(`write: failed to update ${versionRel} (is it valid JSON?).`, 3);
}
process.stdout.write(JSON.stringify({ wrote: version, versionPath: versionRel, packageJson: true }) + "\n");
return;
}
writeFileSync(versionPath, version + "\n");
if (existsSync(join(cwd, "package.json"))) {
try {
@@ -177,7 +210,17 @@ function cmdWrite(args: string[], cwd: string): void {
function cmdRepair(args: string[], cwd: string): void {
const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path"));
const current = readVersionFile(versionPath);
const versionRel = argVal(args, "--version-path") ?? "VERSION";
// Nothing to repair when the version lives in a package.json (#2501): there
// is no second file to drift from, and classify never reports DRIFT_* for
// that shape.
if (isJsonVersionPath(versionRel)) {
process.stdout.write(
JSON.stringify({ repaired: null, reason: `${versionRel} is the single source of truth; no drift possible` }) + "\n",
);
return;
}
const current = readVersionFile(versionPath, versionRel);
if (!VERSION_RE.test(current)) {
fail(
`VERSION file contents (${current}) do not match MAJOR.MINOR.PATCH.MICRO. ` +