mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 14:38:59 +02:00
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>
298 lines
12 KiB
TypeScript
Executable File
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);
|
|
}
|
|
}
|