mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 22:48:57 +02:00
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>
This commit is contained in:
co-authored by
YR
Claude Fable 5
parent
c7faef885b
commit
d7ab20ac06
@@ -13,6 +13,8 @@ import {
|
||||
fmtVersion,
|
||||
bumpVersion,
|
||||
cmpVersion,
|
||||
versionWidth,
|
||||
extractVersion,
|
||||
pickNextSlot,
|
||||
markActiveSiblings,
|
||||
resolveVersionPath,
|
||||
@@ -29,8 +31,20 @@ describe("parseVersion", () => {
|
||||
expect(parseVersion(" 1.2.3.4 \n")).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
test("accepts 3-digit semver, padding the micro slot (#2501)", () => {
|
||||
// 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 +53,49 @@ describe("parseVersion", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("3-digit repos keep their width (#2501)", () => {
|
||||
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 (#2501)", () => {
|
||||
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]);
|
||||
|
||||
@@ -39,10 +39,18 @@ 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 (#2501)', () => {
|
||||
// A repo whose pinned version source 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 +71,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 +139,74 @@ 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 (#2501). 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.
|
||||
*
|
||||
* These cases pass --version-path explicitly; the .gstack/version-path pin
|
||||
* flows through the same reader once classify/write/repair resolve the pin's
|
||||
* repo-relative form (#2462, covered in its own suite below the pin fix).
|
||||
*/
|
||||
describe('package.json as the version source (monorepo, 3-digit, #2501)', () => {
|
||||
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');
|
||||
|
||||
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 package.json version-path', () => {
|
||||
const out = execFileSync('bun', [BIN, 'classify', '--base', 'main', '--version-path', pkgRel], { 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 package.json in place and creates no VERSION file', () => {
|
||||
const out = execFileSync('bun', [BIN, 'write', '--version', '0.99.3', '--version-path', pkgRel], { 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', '--version-path', pkgRel], { 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', '--version-path', pkgRel], { 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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user