From 890dba871b64097341375a59958c5fa3237caece Mon Sep 17 00:00:00 2001 From: Christoph Date: Wed, 20 May 2026 21:43:58 +0700 Subject: [PATCH] feat(next-version): support monorepo VERSION paths via --version-path + .gstack/version-path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workspace-aware ship queue hardcoded the VERSION file at the repo root. In monorepos where versioning is subproject-scoped (one app inside a larger repo), every PR's VERSION lookup 404s, the queue silently empties, and parallel /ship sessions all bump from "current main + 1" — producing a cascade of slot collisions. Repro: tinas-second-brain repo. Root VERSION is absent; the real VERSION lives at "Tinas Second Brain/health-tracker/VERSION". In one day, four sequential collisions: 0.4.0.1 -> 0.5.0.0 -> 0.5.0.1 -> 0.5.0.2 -> 0.5.0.3. Fix: add a --version-path flag and a repo-local .gstack/version-path config file. Resolution priority: CLI flag > .gstack/version-path > "VERSION". The resolved path threads through all four call sites — git show origin/:, the GitHub Contents API, the GitLab files API, and the local sibling-worktree scan — and shows up in the JSON output as version_path so /ship and operators can see what got picked. The previous warning "could not fetch VERSION (fork or private)" was misleading whenever the real cause was wrong path. The new wording names the path that 404'd and hints at the two knobs. Backward-compatible: no flag, no config, no change in behavior. Tests: 6 unit tests for resolveVersionPath (priority, parsing, blank / missing / empty edge cases) + a second integration smoke that drives --version-path end-to-end and asserts it surfaces in JSON output. --- bin/gstack-next-version | 81 +++++++++++++++++++++------- test/gstack-next-version.test.ts | 93 ++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 20 deletions(-) diff --git a/bin/gstack-next-version b/bin/gstack-next-version index e10485d962..455dd72f2e 100755 --- a/bin/gstack-next-version +++ b/bin/gstack-next-version @@ -10,7 +10,14 @@ // // Usage: // gstack-next-version --base --bump \ -// --current-version [--workspace-root |null] [--json] +// --current-version [--workspace-root |null] \ +// [--version-path ] [--json] +// +// VERSION path resolution (monorepo support): +// 1. --version-path CLI flag (highest priority) +// 2. .gstack/version-path file at the repo root (single-line relative path, +// committed so all collaborators benefit) +// 3. "VERSION" at the repo root (default, backward-compatible) // // Exit codes: // 0 — emitted JSON successfully (may include "offline":true or "host":"unknown") @@ -45,6 +52,7 @@ type Output = { version: string; current_version: string; base_version: string; + version_path: string; bump: Bump; host: "github" | "gitlab" | "unknown"; offline: boolean; @@ -114,6 +122,28 @@ function runCommand(cmd: string, args: string[], timeoutMs = 15000): { ok: boole }; } +// VERSION-path resolution for monorepos. Priority: CLI flag > .gstack/version-path +// at repo root > "VERSION". Pure function; takes the repo root as an argument so +// tests can drive it with a fixture dir without mocking git. +function resolveVersionPath(override: string | undefined, repoRoot: string): string { + if (override) return override.trim(); + const configFile = join(repoRoot, ".gstack", "version-path"); + if (existsSync(configFile)) { + try { + const firstLine = readFileSync(configFile, "utf8").split("\n")[0]?.trim() ?? ""; + if (firstLine) return firstLine; + } catch { + // fall through to default + } + } + return "VERSION"; +} + +function repoToplevel(): string { + const r = runCommand("git", ["rev-parse", "--show-toplevel"]); + return r.ok ? r.stdout.trim() : process.cwd(); +} + function detectHost(): "github" | "gitlab" | "unknown" { const remote = runCommand("git", ["remote", "get-url", "origin"]); if (remote.ok) { @@ -128,19 +158,19 @@ function detectHost(): "github" | "gitlab" | "unknown" { return "unknown"; } -function readBaseVersion(base: string, warnings: string[]): string { +function readBaseVersion(base: string, versionPath: string, warnings: string[]): string { // git fetch is best-effort; we tolerate failure and fall back to whatever // origin/ currently points at. runCommand("git", ["fetch", "origin", base, "--quiet"], 10000); - const r = runCommand("git", ["show", `origin/${base}:VERSION`]); + const r = runCommand("git", ["show", `origin/${base}:${versionPath}`]); if (!r.ok) { - warnings.push(`could not read VERSION at origin/${base}; assuming 0.0.0.0`); + warnings.push(`could not read ${versionPath} at origin/${base}; assuming 0.0.0.0`); return "0.0.0.0"; } return r.stdout.trim(); } -async function fetchGithubClaimed(base: string, excludePR: number | null, warnings: string[]): Promise<{ claimed: ClaimedPR[]; offline: boolean }> { +async function fetchGithubClaimed(base: string, versionPath: string, excludePR: number | null, warnings: string[]): Promise<{ claimed: ClaimedPR[]; offline: boolean }> { const list = runCommand("gh", [ "pr", "list", @@ -187,14 +217,18 @@ async function fetchGithubClaimed(base: string, excludePR: number | null, warnin const pr = queue.shift(); if (!pr) return; // gh passes branch name via argv, not shell — safe. + // encodeURI handles spaces in subproject paths (e.g. "Tinas Second Brain/...") + // while leaving "/" untouched so the GitHub Contents API gets the path intact. const content = runCommand("gh", [ "api", - `repos/{owner}/{repo}/contents/VERSION?ref=${encodeURIComponent(pr.headRefName)}`, + `repos/{owner}/{repo}/contents/${encodeURI(versionPath)}?ref=${encodeURIComponent(pr.headRefName)}`, "-q", ".content", ]); if (!content.ok) { - warnings.push(`PR #${pr.number}: could not fetch VERSION (fork or private)`); + warnings.push( + `PR #${pr.number}: could not fetch ${versionPath} (fork, private, or wrong path — try --version-path or .gstack/version-path)`, + ); continue; } let versionStr: string; @@ -215,7 +249,7 @@ async function fetchGithubClaimed(base: string, excludePR: number | null, warnin return { claimed: results, offline: false }; } -async function fetchGitlabClaimed(base: string, excludePR: number | null, warnings: string[]): Promise<{ claimed: ClaimedPR[]; offline: boolean }> { +async function fetchGitlabClaimed(base: string, versionPath: string, excludePR: number | null, warnings: string[]): Promise<{ claimed: ClaimedPR[]; offline: boolean }> { const list = runCommand("glab", [ "mr", "list", @@ -243,12 +277,15 @@ async function fetchGitlabClaimed(base: string, excludePR: number | null, warnin } const results: ClaimedPR[] = []; for (const mr of mrs) { + // GitLab files API takes the full path URL-encoded (slashes become %2F). const content = runCommand("glab", [ "api", - `projects/:id/repository/files/VERSION?ref=${encodeURIComponent(mr.source_branch)}`, + `projects/:id/repository/files/${encodeURIComponent(versionPath)}?ref=${encodeURIComponent(mr.source_branch)}`, ]); if (!content.ok) { - warnings.push(`MR !${mr.iid}: could not fetch VERSION`); + warnings.push( + `MR !${mr.iid}: could not fetch ${versionPath} (wrong path? — try --version-path or .gstack/version-path)`, + ); continue; } try { @@ -285,7 +322,7 @@ function currentRepoSlug(): string { return m ? m[1] : ""; } -function scanSiblings(root: string | null, claimed: ClaimedPR[], warnings: string[]): Sibling[] { +function scanSiblings(root: string | null, versionPath: string, claimed: ClaimedPR[], warnings: string[]): Sibling[] { if (!root || !existsSync(root)) return []; const mySlug = currentRepoSlug(); if (!mySlug) { @@ -308,7 +345,7 @@ function scanSiblings(root: string | null, claimed: ClaimedPR[], warnings: strin continue; } if (!existsSync(join(p, ".git")) && !existsSync(join(p, ".git/HEAD"))) continue; - const versionFile = join(p, "VERSION"); + const versionFile = join(p, versionPath); if (!existsSync(versionFile)) continue; let version: string; try { @@ -346,12 +383,13 @@ function markActiveSiblings(siblings: Sibling[], baseVersion: Version): Sibling[ }); } -function parseArgs(argv: string[]): { base: string; bump: Bump; current: string; workspaceRoot?: string; excludePR: number | null; help: boolean } { +function parseArgs(argv: string[]): { base: string; bump: Bump; current: string; workspaceRoot?: string; excludePR: number | null; versionPath?: string; help: boolean } { let base = ""; let bump: Bump | "" = ""; let current = ""; let workspaceRoot: string | undefined; let excludePR: number | null = null; + let versionPath: string | undefined; let help = false; for (let i = 0; i < argv.length; i++) { const a = argv[i]; @@ -359,6 +397,7 @@ function parseArgs(argv: string[]): { base: string; bump: Bump; current: string; else if (a === "--bump") bump = (argv[++i] ?? "") as Bump; else if (a === "--current-version") current = argv[++i] ?? ""; else if (a === "--workspace-root") workspaceRoot = argv[++i]; + else if (a === "--version-path") versionPath = argv[++i]; else if (a === "--exclude-pr") { const n = Number(argv[++i]); excludePR = Number.isFinite(n) && n > 0 ? n : null; @@ -375,7 +414,7 @@ function parseArgs(argv: string[]): { base: string; bump: Bump; current: string; console.error(`Error: --bump must be major|minor|patch|micro (got ${bump})`); process.exit(2); } - return { base, bump: bump as Bump, current, workspaceRoot, excludePR, help: false }; + return { base, bump: bump as Bump, current, workspaceRoot, excludePR, versionPath, help: false }; } // Auto-detect: if --exclude-pr wasn't passed, check whether the current branch @@ -392,13 +431,14 @@ async function main() { const args = parseArgs(process.argv.slice(2)); if (args.help) { console.log( - "Usage: gstack-next-version --base --bump --current-version [--workspace-root ]", + "Usage: gstack-next-version --base --bump --current-version [--workspace-root ] [--version-path ]", ); process.exit(0); } const warnings: string[] = []; const host = detectHost(); - const baseVersion = args.current || readBaseVersion(args.base, warnings); + const versionPath = resolveVersionPath(args.versionPath, repoToplevel()); + const baseVersion = args.current || readBaseVersion(args.base, versionPath, warnings); const baseParsed = parseVersion(baseVersion); if (!baseParsed) { console.error(`Error: could not parse base version '${baseVersion}'`); @@ -413,9 +453,9 @@ async function main() { let claimed: ClaimedPR[] = []; let offline = false; if (host === "github") { - ({ claimed, offline } = await fetchGithubClaimed(args.base, excludePR, warnings)); + ({ claimed, offline } = await fetchGithubClaimed(args.base, versionPath, excludePR, warnings)); } else if (host === "gitlab") { - ({ claimed, offline } = await fetchGitlabClaimed(args.base, excludePR, warnings)); + ({ claimed, offline } = await fetchGitlabClaimed(args.base, versionPath, excludePR, warnings)); } else { warnings.push("host unknown; queue-awareness unavailable"); } @@ -433,7 +473,7 @@ async function main() { const { version: picked, reason } = pickNextSlot(baseParsed, claimedVersions, args.bump); const workspaceRoot = resolveWorkspaceRoot(args.workspaceRoot); - const siblings = markActiveSiblings(scanSiblings(workspaceRoot, claimed, warnings), baseParsed); + const siblings = markActiveSiblings(scanSiblings(workspaceRoot, versionPath, claimed, warnings), baseParsed); const activeSiblings = siblings.filter((s) => s.is_active); // If an active sibling outranks our pick, bump past it (same bump level). @@ -453,6 +493,7 @@ async function main() { version: fmtVersion(finalVersion), current_version: args.current || baseVersion, base_version: baseVersion, + version_path: versionPath, bump: args.bump, host, offline, @@ -466,7 +507,7 @@ async function main() { } // Pure-function exports for testing -export { parseVersion, fmtVersion, bumpVersion, cmpVersion, pickNextSlot, markActiveSiblings }; +export { parseVersion, fmtVersion, bumpVersion, cmpVersion, pickNextSlot, markActiveSiblings, resolveVersionPath }; // Only run main() when invoked as a script, not when imported by tests. if (import.meta.main) { diff --git a/test/gstack-next-version.test.ts b/test/gstack-next-version.test.ts index 71a80d875b..f4ba06926a 100644 --- a/test/gstack-next-version.test.ts +++ b/test/gstack-next-version.test.ts @@ -4,6 +4,9 @@ // when the relevant CLI isn't available). import { test, expect, describe } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { parseVersion, fmtVersion, @@ -11,6 +14,7 @@ import { cmpVersion, pickNextSlot, markActiveSiblings, + resolveVersionPath, } from "../bin/gstack-next-version"; describe("parseVersion", () => { @@ -150,6 +154,73 @@ describe("markActiveSiblings", () => { }); }); +describe("resolveVersionPath (monorepo VERSION-path support)", () => { + test("CLI flag wins over everything", () => { + const dir = mkdtempSync(join(tmpdir(), "nextver-")); + try { + mkdirSync(join(dir, ".gstack")); + writeFileSync(join(dir, ".gstack", "version-path"), "config/VERSION\n"); + expect(resolveVersionPath("flag/path/VERSION", dir)).toBe("flag/path/VERSION"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test(".gstack/version-path config is picked up", () => { + const dir = mkdtempSync(join(tmpdir(), "nextver-")); + try { + mkdirSync(join(dir, ".gstack")); + writeFileSync(join(dir, ".gstack", "version-path"), "Tinas Second Brain/health-tracker/VERSION\n"); + expect(resolveVersionPath(undefined, dir)).toBe("Tinas Second Brain/health-tracker/VERSION"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("trims whitespace and ignores blank lines after the first", () => { + const dir = mkdtempSync(join(tmpdir(), "nextver-")); + try { + mkdirSync(join(dir, ".gstack")); + writeFileSync(join(dir, ".gstack", "version-path"), " apps/web/VERSION \n\n# comment-ish line\n"); + expect(resolveVersionPath(undefined, dir)).toBe("apps/web/VERSION"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("empty config file falls back to default VERSION", () => { + const dir = mkdtempSync(join(tmpdir(), "nextver-")); + try { + mkdirSync(join(dir, ".gstack")); + writeFileSync(join(dir, ".gstack", "version-path"), "\n"); + expect(resolveVersionPath(undefined, dir)).toBe("VERSION"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("missing config file falls back to default VERSION", () => { + const dir = mkdtempSync(join(tmpdir(), "nextver-")); + try { + expect(resolveVersionPath(undefined, dir)).toBe("VERSION"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("empty override string falls back to config/default", () => { + // Defensive: "" should NOT win over config — only a non-empty CLI arg should. + const dir = mkdtempSync(join(tmpdir(), "nextver-")); + try { + mkdirSync(join(dir, ".gstack")); + writeFileSync(join(dir, ".gstack", "version-path"), "subproj/VERSION\n"); + expect(resolveVersionPath("", dir)).toBe("subproj/VERSION"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + // Integration smoke — only runs if gh is available and authenticated. Confirms // the CLI executes end-to-end against real APIs without crashing. describe("integration (smoke)", () => { @@ -181,5 +252,27 @@ describe("integration (smoke)", () => { expect(Array.isArray(parsed.claimed)).toBe(true); expect(parsed).toHaveProperty("siblings"); expect(parsed.siblings).toEqual([]); // --workspace-root null disabled scanning + expect(parsed).toHaveProperty("version_path", "VERSION"); // default when no config + no flag }, 30_000); // Headroom over the 4-5s wall time of the spawned process under load + + test("CLI runs with --version-path and surfaces it in JSON output", async () => { + const proc = Bun.spawnSync([ + "bun", + "run", + "./bin/gstack-next-version", + "--base", + "main", + "--bump", + "patch", + "--current-version", + "1.6.3.0", + "--workspace-root", + "null", + "--version-path", + "Tinas Second Brain/health-tracker/VERSION", + ]); + const out = new TextDecoder().decode(proc.stdout); + const parsed = JSON.parse(out); + expect(parsed).toHaveProperty("version_path", "Tinas Second Brain/health-tracker/VERSION"); + }, 30_000); });