mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 22:48:57 +02:00
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>
119 lines
5.2 KiB
TypeScript
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";
|
|
}
|