#!/usr/bin/env bun // gstack-version-bump — deterministic version-state classifier + writer for /ship. // // Extracted from ship Step 12 prose (v2 plan T9, hybrid CLI extraction). The // idempotency classification and the dual-write to VERSION + package.json are // pure deterministic logic; running them as tested code removes the single // worst /ship footgun — re-bumping an already-shipped branch — from prose the // agent could skip or misread when the step lives in a lazy-loaded section. // // What STAYS agent judgment (NOT here): the bump-LEVEL decision (micro/patch vs // minor/major, which may AskUserQuestion on feature signals) and the queue // collision prompt. The slot pick itself is bin/gstack-next-version. This CLI // only answers "what state am I in?" and "write this exact version". // // Subcommands: // classify --base [--version-path

] // Compares VERSION vs origin/:VERSION vs package.json.version. // Emits JSON: { state, baseVersion, currentVersion, pkgVersion, pkgExists } // state ∈ FRESH | ALREADY_BUMPED | DRIFT_STALE_PKG | DRIFT_UNEXPECTED // Exit 0 on a decidable state (incl. DRIFT_UNEXPECTED — it's a real state // the caller must handle), exit 2 on bad args / unresolvable base. // // write --version [--version-path

] // Validates the 4-digit pattern, writes VERSION + package.json.version. // Use for the FRESH bump (or an approved queue rebump). Exit 3 on a // half-write (VERSION written, package.json failed) so the caller knows // drift exists; the next classify() will report DRIFT_STALE_PKG. // // repair [--version-path

] // DRIFT_STALE_PKG path: sync package.json.version to the current VERSION // file. No bump. Validates the VERSION pattern first. // // Contract: classify NEVER writes. write/repair mutate VERSION + package.json // only. No git mutation, no network. Mirrors gstack-next-version's reader/writer // split so /ship composes them. 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"; // 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"; function fail(msg: string, code = 2): never { process.stderr.write(`gstack-version-bump: ${msg}\n`); process.exit(code); } function argVal(args: string[], flag: string): string | undefined { const i = args.indexOf(flag); return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined; } /** Resolve the VERSION file path: --version-path, else .gstack/version-path, else "VERSION". */ function resolveVersionPath(cwd: string, explicit?: string): string { if (explicit) return join(cwd, explicit); const pin = join(cwd, ".gstack", "version-path"); if (existsSync(pin)) { const p = readFileSync(pin, "utf-8").trim(); if (p) return join(cwd, p); } return join(cwd, "VERSION"); } function readVersionFile(p: string, versionRel = "VERSION"): string { try { // 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; } } /** package.json version + existence, parsed without spawning node. */ function readPkgVersion(cwd: string): { exists: boolean; version: string } { const pkgPath = join(cwd, "package.json"); if (!existsSync(pkgPath)) return { exists: false, version: "" }; let raw: string; try { raw = readFileSync(pkgPath, "utf-8"); } catch { return { exists: true, version: "" }; } let parsed: unknown; try { parsed = JSON.parse(raw); } catch { fail("package.json is not valid JSON. Fix the file before re-running /ship.", 2); } const version = (parsed as { version?: unknown })?.version; return { exists: true, version: typeof version === "string" ? version : "" }; } function writePkgVersion(cwd: string, version: string): void { const pkgPath = join(cwd, "package.json"); const raw = readFileSync(pkgPath, "utf-8"); const parsed = JSON.parse(raw) as Record; parsed.version = version; writeFileSync(pkgPath, JSON.stringify(parsed, null, 2) + "\n"); } function baseVersion(cwd: string, base: string, versionRel: string): string { // Verify the base ref resolves, mirroring the Step 12 guard. try { execFileSync("git", ["rev-parse", "--verify", `origin/${base}`], { cwd, stdio: "ignore" }); } catch { fail(`Unable to resolve origin/${base}. Run 'git fetch origin' or verify the base branch exists.`, 2); } try { const out = execFileSync("git", ["show", `origin/${base}:${versionRel}`], { cwd }).toString(); return extractVersion(out, versionRel) || DEFAULT; } catch { // VERSION absent on base (new repo / new file) → treat as 0.0.0.0. return DEFAULT; } } function classifyState(current: string, base: string, pkgExists: boolean, pkgVersion: string): State { if (current === base) { // VERSION unchanged vs base. A diverging package.json means someone hand-edited // package.json bypassing /ship — unsafe to guess which is authoritative. if (pkgExists && pkgVersion && pkgVersion !== current) return "DRIFT_UNEXPECTED"; return "FRESH"; } // VERSION already moved past base. if (pkgExists && pkgVersion && pkgVersion !== current) return "DRIFT_STALE_PKG"; return "ALREADY_BUMPED"; } function cmdClassify(args: string[], cwd: string): void { const base = argVal(args, "--base"); if (!base) fail("classify requires --base ", 2); const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path")); const versionRel = argVal(args, "--version-path") ?? "VERSION"; const current = readVersionFile(versionPath, versionRel); const baseV = baseVersion(cwd, base!, versionRel); // 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({ state, baseVersion: baseV, currentVersion: current, pkgVersion: pkg.version || null, pkgExists: pkg.exists, }) + "\n", ); // DRIFT_UNEXPECTED is a real, decidable state — the caller stops on it, but the // classification itself succeeded, so exit 0. (Bad args / unresolvable base are // the only exit-2 cases.) } function cmdWrite(args: string[], cwd: string): void { const version = argVal(args, "--version"); if (!version) fail("write requires --version ", 2); if (!VERSION_RE.test(version!)) { 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 { writePkgVersion(cwd, version!); } catch { fail( "failed to update package.json. VERSION was written but package.json is now stale. " + "Re-run — classify will report DRIFT_STALE_PKG and repair will sync it.", 3, ); } } process.stdout.write(JSON.stringify({ wrote: version, packageJson: existsSync(join(cwd, "package.json")) }) + "\n"); } function cmdRepair(args: string[], cwd: string): void { const versionPath = resolveVersionPath(cwd, argVal(args, "--version-path")); 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. ` + "Refusing to propagate invalid semver into package.json. Fix VERSION, then re-run /ship.", 2, ); } if (!existsSync(join(cwd, "package.json"))) { fail("repair: no package.json to sync.", 2); } try { writePkgVersion(cwd, current); } catch { fail("drift repair failed — could not update package.json.", 3); } process.stdout.write(JSON.stringify({ repaired: current }) + "\n"); } // Exported for unit tests (pure logic, no I/O). export { classifyState, VERSION_RE, type State }; if (import.meta.main) { const [sub, ...rest] = process.argv.slice(2); const cwd = process.cwd(); switch (sub) { case "classify": cmdClassify(rest, cwd); break; case "write": cmdWrite(rest, cwd); break; case "repair": cmdRepair(rest, cwd); break; default: fail("usage: gstack-version-bump [flags]", 2); } }