diff --git a/bin/gstack-next-version b/bin/gstack-next-version
index 76879ddbd..9188e18e4 100755
--- a/bin/gstack-next-version
+++ b/bin/gstack-next-version
@@ -19,6 +19,12 @@
// 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.
+//
// Exit codes:
// 0 — emitted JSON successfully (may include "offline":true or "host":"unknown")
// 2 — invalid arguments
@@ -28,9 +34,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 +81,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 +154,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 +225,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 +282,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 +341,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 +461,12 @@ 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.
+ 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 +493,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 +508,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 +529,10 @@ 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) {
diff --git a/bin/gstack-version-bump b/bin/gstack-version-bump
index 298fab17d..42b3c3bef 100755
--- a/bin/gstack-version-bump
+++ b/bin/gstack-version-bump
@@ -37,8 +37,12 @@
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. A repo whose single source of truth 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";
@@ -53,20 +57,34 @@ function argVal(args: string[], flag: string): string | undefined {
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);
+/**
+ * Resolve the version file's path RELATIVE to the repo root: --version-path, else
+ * .gstack/version-path, else "VERSION".
+ *
+ * The relative form is what matters — `git show origin/:` needs it. Callers used
+ * to derive it from the CLI flag alone, so a repo using the .gstack/version-path pin had its
+ * local (pinned) version compared against the BASE's root VERSION file: two different files,
+ * and on a repo with no root VERSION the base always read as 0.0.0.0, making every branch
+ * look FRESH. Resolving once, here, keeps the two in step.
+ */
+function resolveVersionRel(cwd: string, explicit?: string): string {
+ if (explicit) return explicit.trim();
const pin = join(cwd, ".gstack", "version-path");
if (existsSync(pin)) {
- const p = readFileSync(pin, "utf-8").trim();
- if (p) return join(cwd, p);
+ const p = readFileSync(pin, "utf-8").split("\n")[0]?.trim() ?? "";
+ if (p) return p;
}
- return join(cwd, "VERSION");
+ return "VERSION";
}
-function readVersionFile(p: string): string {
+/** Absolute path to the version file. */
+function resolveVersionPath(cwd: string, explicit?: string): string {
+ return join(cwd, resolveVersionRel(cwd, explicit));
+}
+
+function readVersionFile(p: string, versionRel = "VERSION"): string {
try {
- const v = readFileSync(p, "utf-8").replace(/[\r\n\s]/g, "");
+ const v = extractVersion(readFileSync(p, "utf-8"), versionRel);
return v || DEFAULT;
} catch {
return DEFAULT;
@@ -110,8 +128,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;
@@ -133,11 +150,17 @@ function classifyState(current: string, base: string, pkgExists: boolean, pkgVer
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);
+ const versionRel = resolveVersionRel(cwd, argVal(args, "--version-path"));
+ const versionPath = join(cwd, versionRel);
+ const current = readVersionFile(versionPath, versionRel);
const baseV = baseVersion(cwd, base!, versionRel);
- const pkg = readPkgVersion(cwd);
+ // When the version-path IS a package.json, 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({
@@ -159,7 +182,27 @@ function cmdWrite(args: string[], cwd: string): void {
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 = resolveVersionRel(cwd, argVal(args, "--version-path"));
+ const versionPath = join(cwd, versionRel);
+
+ // A package.json version-path 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, and 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 {
@@ -176,8 +219,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 = resolveVersionRel(cwd, argVal(args, "--version-path"));
+ const versionPath = join(cwd, versionRel);
+ // Nothing to repair when the version lives in a package.json: 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. ` +
diff --git a/lib/version-source.ts b/lib/version-source.ts
new file mode 100644
index 000000000..d3f817ecc
--- /dev/null
+++ b/lib/version-source.ts
@@ -0,0 +1,108 @@
+// version-source — where a repo's version lives, and how wide it is.
+//
+// gstack's native shape is a plain-text VERSION file at the repo root holding a
+// 4-digit MAJOR.MINOR.PATCH.MICRO. Two real-world shapes did not fit, and both failed
+// CLOSED in a way that silently disabled /ship's version tooling:
+//
+// 1. The version's home is a package.json — often not at the root (a monorepo whose
+// frontend/package.json is the single source of truth because the build injects it).
+// The --version-path / .gstack/version-path pin already let you point anywhere, but
+// the readers treated the target as raw text, so a JSON file parsed as "{" and every
+// version read came back as the 0.0.0.0 fallback.
+// 2. The version is 3-digit semver. parseVersion() required exactly four components, so
+// gstack-next-version exited 2 ("could not parse base version") on every invocation —
+// and that CLI *is* the queue-collision check, so /ship fell through to its documented
+// "offline" path of naive local arithmetic. Two branches cut from the same base then
+// pick the same version, and git merges that without a conflict because both sides set
+// one line to identical text. The duplicate slot ships silently.
+//
+// Both are handled here rather than in each CLI so the two agree by construction.
+//
+// Detection is by shape, not configuration: a version-path ending in .json is read as JSON
+// (.version), anything else as trimmed text; a version string with three components stays
+// three components through bumping and formatting. A repo with a root VERSION file and
+// 4-digit versions sees no behaviour change.
+
+export type Version = [number, number, number, number];
+export type VersionWidth = 3 | 4;
+export type Bump = "major" | "minor" | "patch" | "micro";
+
+/** Parse 3- or 4-component versions. 3-digit pads to [a,b,c,0] so comparison stays uniform. */
+export 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] ?? 0)];
+}
+
+/** How many components the string actually had — what to format back out as. */
+export function versionWidth(s: string): VersionWidth {
+ return /^\d+\.\d+\.\d+\.\d+$/.test(s.trim()) ? 4 : 3;
+}
+
+export function fmtVersion(v: Version, width: VersionWidth = 4): string {
+ return v.slice(0, width).join(".");
+}
+
+export 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;
+}
+
+/**
+ * Bump one level. In a 3-digit repo there is no MICRO component to move, so `micro` is
+ * carried out as a PATCH: /ship auto-picks MICRO by default, and erroring there would make
+ * it unusable in every 3-digit repo — a silent no-op would be worse still, since the
+ * caller would then write back the version it started with and claim a taken slot.
+ */
+export function bumpVersion(v: Version, level: Bump, width: VersionWidth = 4): Version {
+ const effective: Bump = width === 3 && level === "micro" ? "patch" : level;
+ switch (effective) {
+ 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];
+ }
+}
+
+/** True when the effective bump differs from the one asked for (so callers can say so). */
+export function bumpWasCoerced(level: Bump, width: VersionWidth): boolean {
+ return width === 3 && level === "micro";
+}
+
+/** A version-path pointing at a .json is read as JSON, not as raw text. */
+export function isJsonVersionPath(versionPath: string): boolean {
+ return /\.json$/i.test(versionPath.trim());
+}
+
+/**
+ * Pull the version out of whatever the version-path resolves to. `text` is the file's
+ * contents from anywhere — local read, `git show`, or a base64-decoded API response — so
+ * every reader agrees on interpretation. Returns "" when there is no usable version, which
+ * callers map to their own fallback.
+ */
+export function extractVersion(text: string, versionPath: string): string {
+ if (!isJsonVersionPath(versionPath)) return text.replace(/[\r\n\s]/g, "");
+ try {
+ const parsed = JSON.parse(text) as { version?: unknown };
+ return typeof parsed?.version === "string" ? parsed.version.trim() : "";
+ } catch {
+ return "";
+ }
+}
+
+/**
+ * Write a version back into a JSON file, preserving the rest of it. Deliberately
+ * key-order-preserving (JSON.parse/stringify keeps insertion order) and 2-space indented
+ * with a trailing newline, matching what package managers write.
+ */
+export function setVersionInJson(raw: string, version: string): string {
+ const parsed = JSON.parse(raw) as Record;
+ parsed.version = version;
+ return JSON.stringify(parsed, null, 2) + "\n";
+}
diff --git a/test/gstack-next-version.test.ts b/test/gstack-next-version.test.ts
index f72ec0149..793b10a46 100644
--- a/test/gstack-next-version.test.ts
+++ b/test/gstack-next-version.test.ts
@@ -13,6 +13,8 @@ import {
fmtVersion,
bumpVersion,
cmpVersion,
+ versionWidth,
+ extractVersion,
pickNextSlot,
markActiveSiblings,
resolveVersionPath,
@@ -29,8 +31,19 @@ describe("parseVersion", () => {
expect(parseVersion(" 1.2.3.4 \n")).toEqual([1, 2, 3, 4]);
});
+ test("accepts 3-digit semver, padding the micro slot", () => {
+ // 3-digit repos (a package.json holding plain semver) used to fail parsing outright,
+ // which exited this CLI 2 on EVERY run — and since this CLI is the queue-collision
+ // check, /ship then fell back to naive local arithmetic and duplicate version slots
+ // shipped silently. The pad keeps comparison uniform; versionWidth narrows output back.
+ expect(parseVersion("0.99.2")).toEqual([0, 99, 2, 0]);
+ expect(parseVersion("1.2.3")).toEqual([1, 2, 3, 0]);
+ expect(versionWidth("0.99.2")).toBe(3);
+ expect(versionWidth("1.6.3.0")).toBe(4);
+ });
+
test("rejects malformed", () => {
- expect(parseVersion("1.2.3")).toBeNull();
+ expect(parseVersion("1.2")).toBeNull();
expect(parseVersion("1.2.3.4.5")).toBeNull();
expect(parseVersion("v1.2.3.4")).toBeNull();
expect(parseVersion("")).toBeNull();
@@ -39,6 +52,48 @@ describe("parseVersion", () => {
});
});
+describe("3-digit repos keep their width", () => {
+ test("formatting narrows to the repo's own width", () => {
+ expect(fmtVersion([0, 99, 3, 0], 3)).toBe("0.99.3");
+ expect(fmtVersion([0, 99, 3, 0], 4)).toBe("0.99.3.0");
+ expect(fmtVersion([0, 99, 3, 0])).toBe("0.99.3.0"); // default stays 4-digit
+ });
+
+ test("micro is carried out as patch when there is no micro component", () => {
+ // /ship auto-picks MICRO by default. Erroring would make it unusable in every 3-digit
+ // repo; a no-op would be worse — it would write back the version it started with and
+ // claim a slot already taken.
+ expect(bumpVersion([0, 99, 2, 0], "micro", 3)).toEqual([0, 99, 3, 0]);
+ expect(bumpVersion([0, 99, 2, 0], "patch", 3)).toEqual([0, 99, 3, 0]);
+ expect(bumpVersion([0, 99, 2, 3], "micro", 4)).toEqual([0, 99, 2, 4]); // 4-digit unchanged
+ });
+
+ test("slot picking stays inside the repo's width", () => {
+ const { version } = pickNextSlot([0, 99, 2, 0], [[0, 99, 5, 0]], "patch", 3);
+ expect(fmtVersion(version, 3)).toBe("0.99.6");
+ });
+});
+
+describe("extractVersion", () => {
+ test("reads .version when the version-path is a package.json", () => {
+ const pkg = JSON.stringify({ name: "frontend", version: "0.99.2", private: true });
+ expect(extractVersion(pkg, "frontend/package.json")).toBe("0.99.2");
+ expect(extractVersion(pkg, "deep/nested/package.json")).toBe("0.99.2");
+ });
+
+ test("reads raw text for a plain VERSION file", () => {
+ expect(extractVersion("1.6.3.0\n", "VERSION")).toBe("1.6.3.0");
+ expect(extractVersion(" 1.6.3.0 ", "version/CURRENT")).toBe("1.6.3.0");
+ });
+
+ test("a JSON path that isn't valid JSON yields empty, not garbage", () => {
+ // The old readers ran a package.json through a whitespace strip and handed the caller
+ // '{"name":"frontend",...' as if it were a version. Empty lets callers fall back loudly.
+ expect(extractVersion("{ not json", "package.json")).toBe("");
+ expect(extractVersion(JSON.stringify({ name: "x" }), "package.json")).toBe("");
+ });
+});
+
describe("bumpVersion", () => {
test("major zeros everything right", () => {
expect(bumpVersion([1, 6, 3, 0], "major")).toEqual([2, 0, 0, 0]);
diff --git a/test/gstack-version-bump.test.ts b/test/gstack-version-bump.test.ts
index ffcecd1a7..583fde116 100644
--- a/test/gstack-version-bump.test.ts
+++ b/test/gstack-version-bump.test.ts
@@ -39,10 +39,17 @@ describe('VERSION_RE', () => {
test('accepts 4-digit semver', () => {
expect(VERSION_RE.test('1.2.3.4')).toBe(true);
});
- test('rejects 3-digit and garbage', () => {
- expect(VERSION_RE.test('1.2.3')).toBe(false);
+ test('accepts 3-digit semver too', () => {
+ // A repo whose single source of truth is a package.json holds plain 3-digit semver.
+ // Rejecting it meant /ship could not write a version in such a repo at all.
+ expect(VERSION_RE.test('1.2.3')).toBe(true);
+ expect(VERSION_RE.test('0.99.2')).toBe(true);
+ });
+ test('rejects garbage', () => {
+ expect(VERSION_RE.test('1.2')).toBe(false);
expect(VERSION_RE.test('v1.2.3.4')).toBe(false);
expect(VERSION_RE.test('1.2.3.4-rc')).toBe(false);
+ expect(VERSION_RE.test('1.2.3.4.5')).toBe(false);
});
});
@@ -63,7 +70,7 @@ describe('write (FRESH bump)', () => {
test('rejects a malformed version with exit 2', () => {
let code = 0;
- try { execFileSync('bun', [BIN, 'write', '--version', '1.2.3'], { cwd: dir, stdio: 'pipe' }); }
+ try { execFileSync('bun', [BIN, 'write', '--version', '1.2.3.4.5'], { cwd: dir, stdio: 'pipe' }); }
catch (e: any) { code = e.status; }
expect(code).toBe(2);
});
@@ -131,3 +138,72 @@ describe('classify (idempotency over a real git base)', () => {
expect(parsed.currentVersion).toBe('1.1.0.0');
});
});
+
+/**
+ * A repo whose single source of truth is a package.json at a non-root path, holding plain
+ * 3-digit semver — the shape gstack's native VERSION-file assumption failed closed on.
+ * Before this, classify reported {state: FRESH, baseVersion: "0.0.0.0", pkgExists: false}
+ * no matter what the repo's real version was: it looked for a root VERSION file and a root
+ * package.json, found neither, and reported a pristine repo at version zero.
+ */
+describe('package.json as the version source (monorepo, 3-digit)', () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-pkgsrc-'));
+ afterAll(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } });
+
+ const pkgRel = 'frontend/package.json';
+ const pkgAbs = path.join(dir, pkgRel);
+ fs.mkdirSync(path.join(dir, 'frontend'), { recursive: true });
+ fs.writeFileSync(pkgAbs, JSON.stringify({ name: 'frontend', version: '0.99.2', private: true, scripts: { dev: 'next dev' } }, null, 2) + '\n');
+ // Pin it the committed way, so this also covers the pin being honoured for the BASE read.
+ fs.mkdirSync(path.join(dir, '.gstack'), { recursive: true });
+ fs.writeFileSync(path.join(dir, '.gstack', 'version-path'), pkgRel + '\n');
+
+ execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir });
+ execFileSync('git', ['config', 'user.email', 't@e.com'], { cwd: dir });
+ execFileSync('git', ['config', 'user.name', 't'], { cwd: dir });
+ execFileSync('git', ['add', '-A'], { cwd: dir });
+ execFileSync('git', ['commit', '-qm', 'v0.99.2 base'], { cwd: dir });
+ const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir }).toString().trim();
+ fs.mkdirSync(path.join(dir, '.git', 'refs', 'remotes', 'origin'), { recursive: true });
+ fs.writeFileSync(path.join(dir, '.git', 'refs', 'remotes', 'origin', 'main'), head + '\n');
+
+ test('classify reads the real version from the pinned package.json', () => {
+ const out = execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: dir }).toString();
+ const parsed = JSON.parse(out);
+ expect(parsed.state).toBe('FRESH');
+ expect(parsed.baseVersion).toBe('0.99.2'); // was "0.0.0.0"
+ expect(parsed.currentVersion).toBe('0.99.2'); // was "0.0.0.0"
+ expect(parsed.pkgExists).toBe(true); // was false
+ });
+
+ test('write updates the pinned package.json in place and creates no VERSION file', () => {
+ const out = execFileSync('bun', [BIN, 'write', '--version', '0.99.3'], { cwd: dir }).toString();
+ expect(JSON.parse(out)).toEqual({ wrote: '0.99.3', versionPath: pkgRel, packageJson: true });
+ const pkg = JSON.parse(fs.readFileSync(pkgAbs, 'utf-8'));
+ expect(pkg.version).toBe('0.99.3');
+ expect(pkg.scripts).toEqual({ dev: 'next dev' }); // rest of the file untouched
+ expect(pkg.name).toBe('frontend');
+ expect(fs.existsSync(path.join(dir, 'VERSION'))).toBe(false);
+ });
+
+ test('classify reports ALREADY_BUMPED after that write, not a drift state', () => {
+ const out = execFileSync('bun', [BIN, 'classify', '--base', 'main'], { cwd: dir }).toString();
+ const parsed = JSON.parse(out);
+ expect(parsed.state).toBe('ALREADY_BUMPED');
+ expect(parsed.baseVersion).toBe('0.99.2');
+ expect(parsed.currentVersion).toBe('0.99.3');
+ });
+
+ test('repair is a no-op: there is no second file to drift from', () => {
+ const out = execFileSync('bun', [BIN, 'repair'], { cwd: dir }).toString();
+ expect(JSON.parse(out).repaired).toBeNull();
+ });
+
+ test('write refuses a version-path that does not exist', () => {
+ let code = 0;
+ try {
+ execFileSync('bun', [BIN, 'write', '--version', '1.0.0', '--version-path', 'nope/package.json'], { cwd: dir, stdio: 'pipe' });
+ } catch (e: any) { code = e.status; }
+ expect(code).toBe(2);
+ });
+});