gstack/lib/version-source.ts

109 lines
4.8 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. 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<string, unknown>;
parsed.version = version;
return JSON.stringify(parsed, null, 2) + "\n";
}