Files
gstack/lib/version-source.ts
T
d7ab20ac06 feat: accept 3-digit semver + package.json version sources (#2501)
Two version-source shapes failed CLOSED in a way that silently disabled
/ship's queue-collision check:

1. A --version-path / .gstack/version-path target that is a package.json
   was read as raw text: the whitespace strip turned the JSON into
   '{"name":"frontend",... which parseVersion rejected, so every read —
   local, `git show`, and rival PRs' claims through the GitHub/GitLab
   Contents APIs — fell back to 0.0.0.0 and competing claims were dropped
   as "malformed".
2. parseVersion required exactly four components, so gstack-next-version
   exited 2 on EVERY invocation in a 3-digit repo. That CLI IS the
   queue-collision check; /ship then took its documented offline path of
   naive local arithmetic, two branches cut from the same base picked the
   same version, and git merged the duplicate without a conflict.

New lib/version-source.ts holds the shared semantics so both CLIs agree
by construction: parseVersion accepts 3- or 4-digit (3 pads the micro
slot for uniform comparison), versionWidth/fmtVersion keep a 3-digit repo
3-digit through bumping and formatting, micro coerces to patch on 3-digit
repos (with a warning in the output), and extractVersion reads a .json
version-path as JSON (.version) from any byte source. gstack-version-bump
treats a package.json version-path as that repo's single source of truth
(written in place, DRIFT_* states can't arise — no second file to drift
from). Detection is by shape, not new configuration.

Scope per the wave plan's version-tooling end-state spec (decision 11,
ENG-OV1): this is the READING capability + 3-digit acceptance ONLY.
gstack's own VERSION file stays the 4-digit source of truth; nothing here
flips authority to package.json. The PR's bundled fix for the
.gstack/version-path pin being ignored by classify's base read lands
separately (#2462) — these tests drive the JSON version-path through the
explicit --version-path flag.

Re-derived from PR #2501 by @YiftahR (73 tests pass across
test/gstack-version-bump.test.ts, test/gstack-next-version.test.ts,
test/ship-version-sync.test.ts).

Fixes #2501

Co-authored-by: YR <work.yiftah.rottem@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 10:02:31 -07:00

119 lines
5.2 KiB
TypeScript

// 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 — and for gstack itself that file STAYS the
// source of truth (decision pinned in the v1.67 fix-wave plan: package.json is
// a translated mirror, never the authority). This module exists for the two
// real-world shapes that did not fit and both failed CLOSED in a way that
// silently disabled /ship's version tooling (#2501):
//
// 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 was whitespace-stripped into `{"name":"frontend",...` and
// every version read came back as the 0.0.0.0 fallback — including rival
// PRs' claims fetched through the GitHub Contents API, which were then
// dropped as "malformed".
// 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.
//
// Re-derived from PR #2501 by @YiftahR.
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<string, unknown>;
parsed.version = version;
return JSON.stringify(parsed, null, 2) + "\n";
}