mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-10 15:09:00 +02:00
Two gaps in gstack-version-bump's manifest handling, resolved to the wave plan's version-tooling end-state spec (decision 11): 1. Subdirectory manifests. A repo whose only Node package lives in web/, app/, or frontend/ has no ROOT package.json, so join(cwd, "package.json") reported pkgExists:false and every bump silently wrote VERSION alone — leaving the manifest to be bumped by hand, which is exactly the drift this tool exists to prevent, in the one layout where it silently did nothing. All three subcommands now resolve the manifest as --package-json-path → .gstack/package-json-path → ./package.json (mirroring resolveVersionPath). 2. npm-valid mirror. VERSION is 4-digit MAJOR.MINOR.PATCH.MICRO; npm's semver is 3-component and rejects a fourth, so mirroring the raw form breaks `npm ci` in any repo npm actually manages. The manifest and its lockfiles now carry the npm-valid 3-digit translation (1.67.0.0 → 1.67.0) via npmVersion() in lib/version-source.ts. VERSION stays the 4-digit source of truth. classify judges drift against the TRANSLATED form — a correctly-synced `0.1.25` no longer reads as eternal drift against `0.1.25.0` — and grandfathers the pre-v1.67 1:1 four-digit mirror as in-sync (flagging it DRIFT_UNEXPECTED would hard-stop /ship on every existing repo on upgrade day; the next write migrates the manifest to the translated form). Lockfiles are synced beside the resolved manifest — including beside a pinned JSON version-path — and only when they already exist. classify output gains pkgPath and expectedPkgVersion for observability; write/repair report packageJsonPath + packageJsonVersion. The /ship Step 12 prose (ship/SKILL.md.tmpl) documents the resolution chain and the translation; SKILL.md files regenerated and ship golden fixtures refreshed in this commit. Tests: subdirectory pin + --package-json-path override, translated-form classify (FRESH/ALREADY_BUMPED, no false drift), grandfathered 1:1 mirror, genuine divergence still drifts, repair to the npm-valid form (33 pass in test/gstack-version-bump.test.ts; 526 pass across the five affected files including goldens and parity). Re-derived from PR #2531 by @CarringtonCreative on top of the 3-digit/ JSON version-source work, under decision 11 (which resolves the PR's lockfile-gated translation in favor of an unconditional npm-valid mirror). Co-authored-by: Carrington Dennis <carrdenn3@gmail.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
383 lines
16 KiB
TypeScript
Executable File
383 lines
16 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 + the manifest
|
|
// + 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.
|
|
//
|
|
// Manifest resolution (all three subcommands accept --package-json-path):
|
|
// --package-json-path <p> → .gstack/package-json-path → ./package.json
|
|
// A repo whose only Node package lives in a subdirectory (web/, app/,
|
|
// frontend/) has no ROOT package.json. The tool used to report
|
|
// pkgExists:false there and write VERSION alone, leaving the manifest to be
|
|
// bumped by hand — the drift this tool exists to prevent, in the one layout
|
|
// where it silently did nothing (#2531).
|
|
//
|
|
// npm semver (decision 11, v1.67 fix-wave plan): VERSION is the 4-digit
|
|
// MAJOR.MINOR.PATCH.MICRO source of truth; npm rejects a fourth component,
|
|
// so the manifest and its lockfiles carry the npm-valid 3-digit translation
|
|
// (1.67.0.0 → 1.67.0). classify judges drift against the translated form
|
|
// (accepting the pre-v1.67 1:1 mirror as in-sync until the next write).
|
|
|
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { execFileSync } from "node:child_process";
|
|
import { dirname, join, relative } from "node:path";
|
|
import { extractVersion, isJsonVersionPath, npmVersion, 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve the manifest path: --package-json-path, else
|
|
* .gstack/package-json-path, else "package.json" (#2531, mirrors
|
|
* resolveVersionPath). A repo whose only Node package lives in a
|
|
* subdirectory (web/, app/, frontend/) has no ROOT package.json, so the
|
|
* old join(cwd, "package.json") reported pkgExists:false and every bump
|
|
* silently wrote VERSION alone — leaving the manifest to be edited by
|
|
* hand, which is exactly the drift this tool exists to prevent.
|
|
*/
|
|
function resolvePkgPath(cwd: string, explicit?: string): string {
|
|
if (explicit) return join(cwd, explicit);
|
|
const pin = join(cwd, ".gstack", "package-json-path");
|
|
if (existsSync(pin)) {
|
|
const p = readFileSync(pin, "utf-8").split("\n")[0]?.trim() ?? "";
|
|
if (p) return join(cwd, p);
|
|
}
|
|
return join(cwd, "package.json");
|
|
}
|
|
|
|
/** package.json version + existence, parsed without spawning node. */
|
|
function readPkgVersion(pkgPath: string): { exists: boolean; version: string } {
|
|
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(`${pkgPath} 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(pkgPath: string, version: string): void {
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `expectedPkg` is what the manifest SHOULD hold for the current VERSION —
|
|
* the npm-valid 3-digit translation (decision 11: npm rejects a fourth
|
|
* component, so a correctly-synced `1.67.0` must not read as drift against
|
|
* `1.67.0.0` forever). The historical 1:1 mirror (pre-v1.67 installs whose
|
|
* package.json still carries the 4-digit form) is also accepted as in-sync;
|
|
* write/repair migrate those to the translated form on the next release.
|
|
*/
|
|
function classifyState(
|
|
current: string,
|
|
base: string,
|
|
pkgExists: boolean,
|
|
pkgVersion: string,
|
|
expectedPkg: string = current,
|
|
): State {
|
|
const pkgAgrees =
|
|
!pkgExists || !pkgVersion || pkgVersion === expectedPkg || pkgVersion === current;
|
|
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 (!pkgAgrees) return "DRIFT_UNEXPECTED";
|
|
return "FRESH";
|
|
}
|
|
// VERSION already moved past base.
|
|
if (!pkgAgrees) 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 jsonSource = isJsonVersionPath(versionRel);
|
|
const pkgPath = jsonSource ? versionPath : resolvePkgPath(cwd, argVal(args, "--package-json-path"));
|
|
const pkg = jsonSource
|
|
? { exists: existsSync(versionPath), version: current === DEFAULT ? "" : current }
|
|
: readPkgVersion(pkgPath);
|
|
// Decision 11: the manifest carries the npm-valid 3-digit translation of
|
|
// the 4-digit VERSION; drift is judged against the translated form. A
|
|
// JSON version-path is its own source of truth, so its expected form is
|
|
// the version itself.
|
|
const expectedPkg = jsonSource ? current : npmVersion(current);
|
|
const state = classifyState(current, baseV, pkg.exists, pkg.version, expectedPkg);
|
|
process.stdout.write(
|
|
JSON.stringify({
|
|
state,
|
|
baseVersion: baseV,
|
|
currentVersion: current,
|
|
pkgVersion: pkg.version || null,
|
|
pkgExists: pkg.exists,
|
|
pkgPath: pkg.exists ? relative(cwd, pkgPath) : null,
|
|
expectedPkgVersion: pkg.exists ? expectedPkg : null,
|
|
}) + "\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);
|
|
}
|
|
let lockSynced: string[] = [];
|
|
try {
|
|
writeFileSync(versionPath, setVersionInJson(readFileSync(versionPath, "utf-8"), version!));
|
|
// The pinned manifest's OWN lockfiles (beside it) stay in step too.
|
|
lockSynced = syncNpmLockfiles(dirname(versionPath), version!);
|
|
} catch {
|
|
fail(`write: failed to update ${versionRel} (is it valid JSON?).`, 3);
|
|
}
|
|
process.stdout.write(
|
|
JSON.stringify({
|
|
wrote: version,
|
|
versionPath: versionRel,
|
|
packageJson: true,
|
|
packageLock: lockSynced.length > 0,
|
|
}) + "\n",
|
|
);
|
|
return;
|
|
}
|
|
|
|
const pkgPath = resolvePkgPath(cwd, argVal(args, "--package-json-path"));
|
|
const hasPkg = existsSync(pkgPath);
|
|
writeFileSync(versionPath, version + "\n");
|
|
let lockSynced: string[] = [];
|
|
// Decision 11: the manifest (and its lockfiles) carry the npm-valid
|
|
// 3-digit translation — npm rejects a fourth component, so mirroring the
|
|
// raw 4-digit form breaks `npm ci` in any repo npm actually manages.
|
|
// VERSION keeps the full 4-digit form; it stays the source of truth.
|
|
const manifestV = npmVersion(version!);
|
|
if (hasPkg) {
|
|
try {
|
|
writePkgVersion(pkgPath, manifestV);
|
|
lockSynced = syncNpmLockfiles(dirname(pkgPath), manifestV);
|
|
} catch {
|
|
fail(
|
|
`failed to update ${relative(cwd, pkgPath)}/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: hasPkg,
|
|
packageJsonPath: hasPkg ? relative(cwd, pkgPath) : null,
|
|
packageJsonVersion: hasPkg ? manifestV : null,
|
|
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,
|
|
);
|
|
}
|
|
const pkgPath = resolvePkgPath(cwd, argVal(args, "--package-json-path"));
|
|
if (!existsSync(pkgPath)) {
|
|
fail(`repair: no package.json to sync (looked at ${relative(cwd, pkgPath)}).`, 2);
|
|
}
|
|
// Decision 11: repair syncs the manifest + lockfiles to the npm-valid
|
|
// 3-digit translation of the current VERSION.
|
|
const manifestV = npmVersion(current);
|
|
try {
|
|
writePkgVersion(pkgPath, manifestV);
|
|
syncNpmLockfiles(dirname(pkgPath), manifestV);
|
|
} catch {
|
|
fail("drift repair failed — could not update package.json/npm lockfiles.", 3);
|
|
}
|
|
process.stdout.write(
|
|
JSON.stringify({
|
|
repaired: current,
|
|
packageJsonPath: relative(cwd, pkgPath),
|
|
packageJsonVersion: manifestV,
|
|
}) + "\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);
|
|
}
|
|
}
|