From cff99ead95ad3b60ed0674fae98b12832779c6ae Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 17 Oct 2026 20:24:11 +0000 Subject: [PATCH] fix(next-version): unknown claims make the pick provisional (complete: false) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-next-version | 11 ++++++- test/gstack-next-version.test.ts | 54 ++++++++++++++++++++++++-------- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/bin/gstack-next-version b/bin/gstack-next-version index 10b147df5..483d2c5ca 100755 --- a/bin/gstack-next-version +++ b/bin/gstack-next-version @@ -8,7 +8,8 @@ // Contract: util NEVER writes files or mutates state. Pure reader + reporter. // /ship consumes the JSON and decides what to do. `unknown_claims` lists the // open PRs whose VERSION could not be read (a 15 s timeout or a non-404 -// failure): their claims are unknown, so the picked slot may collide. +// failure): their claims are unknown, so the picked slot may collide, and +// `complete` is false (a provisional pick, also said on stderr and in warnings). // // Usage: // gstack-next-version --base --bump \ @@ -87,6 +88,7 @@ type Output = { reason: string; warnings: string[]; unknown_claims: number[]; + complete: boolean; }; const ACTIVE_SIBLING_MAX_AGE_S = 24 * 60 * 60; @@ -840,6 +842,12 @@ async function main() { finalReason = `bumped past active sibling ${fmtVersion(highest, width)}`; } + const complete = unknownClaims.length === 0; + if (!complete) { + const provisional = `provisional pick: the VERSION claims of PR ${unknownClaims.map((n) => `#${n}`).join(", ")} are unknown, so ${fmtVersion(finalVersion, width)} is not a verified slot and may collide; rerun, then ask the coordinator before stamping`; + warnings.push(provisional); + console.error(`gstack-next-version: ${provisional}`); + } const out: Output = { version: fmtVersion(finalVersion, width), current_version: args.current || baseVersion, @@ -855,6 +863,7 @@ async function main() { reason: finalReason, warnings, unknown_claims: unknownClaims, + complete, }; process.stdout.write(JSON.stringify(out, null, 2) + "\n"); } diff --git a/test/gstack-next-version.test.ts b/test/gstack-next-version.test.ts index 30cc97240..346456a70 100644 --- a/test/gstack-next-version.test.ts +++ b/test/gstack-next-version.test.ts @@ -1014,6 +1014,8 @@ describe("integration (smoke)", () => { expect(parsed.fallback).toBeNull(); expect(parsed.warnings).toEqual([]); expect(parsed.claimed).toEqual([CLAIM]); // the canned PR's owner matches the canned repo owner + expect(parsed.complete).toBe(true); + expect(parsed.unknown_claims).toEqual([]); expect(parsed.version).toBe("1.6.5.0"); // the canned claim on 1.6.4.0 is skipped } finally { rmSync(stubDir, { recursive: true, force: true }); @@ -1319,29 +1321,55 @@ describe("concurrent VERSION fetches (W2-A3)", () => { expect(r.warnings).toEqual(["PR #52: VERSION fetch timed out after 0.5 s; claim unknown, the slot may collide; rerun, then ask the coordinator"]); }, 30_000); + function cliRepo(dir: string): string { + // A local origin whose path contains github.com pins host:"github" by the URL sniff, so no auth probe or network call runs. + const bare = join(dir, "github.com", "origin.git"); + const work = join(dir, "work"); + const git = (cwd: string, ...args: string[]) => Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd, timeout: 30_000 }); + mkdirSync(bare, { recursive: true }); + mkdirSync(work); + git(bare, "init", "-q", "--bare", "-b", "main"); + git(work, "init", "-q", "-b", "main"); + writeFileSync(join(work, "VERSION"), "1.6.3.0\n"); + git(work, "add", "-A"); + git(work, "commit", "-qm", "v1.6.3.0 chore: base"); + git(work, "remote", "add", "origin", bare); + git(work, "push", "-q", "origin", "main"); + return work; + } + + test("a timed-out worker beside successful ones makes the pick provisional: complete false, on stderr and in warnings", () => { + const dir = mkdtempSync(join(tmpdir(), "nextver-provisional-cli-")); + try { + delayedGh(dir, [{ pr: 91, version: "1.6.4.0", delay: 0 }, { pr: 92, version: "1.6.9.0", delay: 25 }, { pr: 93, version: "1.6.5.0", delay: 0.2 }]); + const proc = Bun.spawnSync(["bun", "run", join(import.meta.dir, "..", "bin", "gstack-next-version"), "--base", "main", "--bump", "patch", + "--current-version", "1.6.3.0", "--workspace-root", "null", "--exclude-pr", "999"], + { cwd: cliRepo(dir), timeout: 40_000, env: { ...process.env, PATH: `${dir}:${process.env.PATH ?? ""}` } }); + const out = JSON.parse(proc.stdout.toString()); + expect(out.claimed.map((c: { pr: number }) => c.pr)).toEqual([91, 93]); + expect(out.unknown_claims).toEqual([92]); + expect(out.complete).toBe(false); + expect(out.version).toBe("1.6.6.0"); + const provisional = "provisional pick: the VERSION claims of PR #92 are unknown, so 1.6.6.0 is not a verified slot and may collide; rerun, then ask the coordinator before stamping"; + expect(out.warnings).toEqual(["PR #92: VERSION fetch timed out after 15 s; claim unknown, the slot may collide; rerun, then ask the coordinator", provisional]); + expect(proc.stderr.toString()).toContain(`gstack-next-version: ${provisional}`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, 45_000); + test("the CLI JSON carries unknown_claims", () => { const dir = mkdtempSync(join(tmpdir(), "nextver-unknown-cli-")); try { delayedGh(dir, [{ pr: 81, version: "1.6.4.0", delay: 0 }, { pr: 82, delay: 0, reply: "502" }]); - // A local origin whose path contains github.com pins host:"github" by the URL sniff, so no auth probe or network call runs. - const bare = join(dir, "github.com", "origin.git"); - const work = join(dir, "work"); - const git = (cwd: string, ...args: string[]) => Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd, timeout: 30_000 }); - mkdirSync(bare, { recursive: true }); - mkdirSync(work); - git(bare, "init", "-q", "--bare", "-b", "main"); - git(work, "init", "-q", "-b", "main"); - writeFileSync(join(work, "VERSION"), "1.6.3.0\n"); - git(work, "add", "-A"); - git(work, "commit", "-qm", "v1.6.3.0 chore: base"); - git(work, "remote", "add", "origin", bare); - git(work, "push", "-q", "origin", "main"); + const work = cliRepo(dir); const proc = Bun.spawnSync(["bun", "run", join(import.meta.dir, "..", "bin", "gstack-next-version"), "--base", "main", "--bump", "patch", "--current-version", "1.6.3.0", "--workspace-root", "null", "--exclude-pr", "999"], { cwd: work, timeout: 30_000, env: { ...process.env, PATH: `${dir}:${process.env.PATH ?? ""}` } }); const out = JSON.parse(proc.stdout.toString()); expect(out.host, proc.stderr.toString()).toBe("github"); expect(out.unknown_claims).toEqual([82]); + expect(out.complete).toBe(false); expect(out.claimed.map((c: { pr: number }) => c.pr)).toEqual([81]); expect(out.version).toBe("1.6.5.0"); expect(out.warnings).toContain("PR #82: VERSION fetch failed (gh: Server Error (HTTP 502)); claim unknown, the slot may collide; rerun, then ask the coordinator");