Files
gstack/bin/gstack-version-bump
T
c33b371f25 fix: write/repair sync npm lockfiles' version fields (#2567)
npm records the package version twice in its lockfiles — top-level
`version` and, in lockfileVersion >= 2, `packages[""].version` (the entry
describing the root package itself) — and `npm install` keeps both in
step. gstack-version-bump write/repair updated VERSION + package.json but
left the lockfile behind, so every /ship bump in an npm repo drifted one
field per release until someone ran npm, dirtying the tree on the next
`npm install` far from the cause.

write and repair now mirror the version into package-lock.json AND
npm-shrinkwrap.json (which shares the format and, when present, is what
npm actually honors) as a pure JSON edit — no npm spawn, no
dependency-tree churn, dependency entries untouched. Per the wave plan's
version-tooling end-state spec (decision 11): synced ONLY when the file
already exists, never created (gstack itself is bun-only). A failed
manifest/lockfile write keeps the existing exit-3 half-write semantics so
classify reports DRIFT_STALE_PKG on re-run instead of hiding the drift.

Tests: 5 new cases in test/gstack-version-bump.test.ts — both lockfile
version fields synced with deps untouched, repair heals a stale lockfile,
lockfileVersion 1 (no packages map) doesn't crash, npm-shrinkwrap.json
synced without inventing a package-lock.json, malformed lockfile exits 3
loudly (26 pass total in the file).

Re-derived from PR #2568 by @ortonom under decision 11.

Fixes #2567

Co-authored-by: ortonom <3261546+ortonom@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:02:32 -07:00

298 lines
12 KiB
TypeScript
Executable File

#!/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 <branch> [--version-path <p>]
// Compares VERSION vs origin/<base>: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 <X.Y.Z.W> [--version-path <p>]
// 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 <p>]
// 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
// + npm lockfiles (package-lock.json / npm-shrinkwrap.json, when present)
// 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<string, unknown>;
parsed.version = version;
writeFileSync(pkgPath, JSON.stringify(parsed, null, 2) + "\n");
}
/**
* npm records the package version twice in its lockfiles — top-level
* `version` and, in lockfileVersion >= 2, `packages[""].version` (the entry
* describing the root package itself) — and `npm install` keeps both in
* step. Nothing else in a release does, so a lockfile left behind drifts one
* field per bump until someone runs npm, dirtying the tree on the next
* `npm install` far from the cause (#2567). Pure JSON edit: no npm spawn,
* no dependency-tree churn.
*
* Synced ONLY when the file already exists — never created (gstack itself
* is bun-only; decision pinned in the v1.67 fix-wave plan).
* npm-shrinkwrap.json shares the format and, when present, is what npm
* actually honors, so both names are covered. Returns the names synced.
*/
const NPM_LOCKFILES = ["package-lock.json", "npm-shrinkwrap.json"];
function syncNpmLockfiles(dir: string, version: string): string[] {
const synced: string[] = [];
for (const name of NPM_LOCKFILES) {
const lockPath = join(dir, name);
if (!existsSync(lockPath)) continue;
const parsed = JSON.parse(readFileSync(lockPath, "utf-8")) as Record<string, unknown>;
parsed.version = version;
const packages = parsed.packages as Record<string, Record<string, unknown>> | undefined;
if (packages && typeof packages[""] === "object" && packages[""] !== null) {
packages[""].version = version;
}
writeFileSync(lockPath, JSON.stringify(parsed, null, 2) + "\n");
synced.push(name);
}
return synced;
}
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 <branch>", 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 <X.Y.Z.W>", 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");
let lockSynced: string[] = [];
if (existsSync(join(cwd, "package.json"))) {
try {
writePkgVersion(cwd, version!);
lockSynced = syncNpmLockfiles(cwd, version!);
} catch {
fail(
"failed to update package.json/npm lockfiles. VERSION was written but the npm " +
"manifests are now stale. Re-run — classify will report DRIFT_STALE_PKG and repair will sync them.",
3,
);
}
}
process.stdout.write(
JSON.stringify({
wrote: version,
packageJson: existsSync(join(cwd, "package.json")),
packageLock: lockSynced.length > 0,
}) + "\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);
syncNpmLockfiles(cwd, current);
} catch {
fail("drift repair failed — could not update package.json/npm lockfiles.", 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 <classify|write|repair> [flags]", 2);
}
}