Files
gstack/bin/gstack-next-version
T
Garry Tanandcapy-ai[bot] a59734fed6 v1.91.132.0 feat(pregate): a remote gate for gstack's own pregate (Ubicloud or keyless local shards); concurrent next-version fetches (#3258)
* feat(test-free): --files-from runs exactly the listed planned files

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(pregate): declare scripts/ubicloud/pregate-remote.sh as gstack's remote gate (keyed Ubicloud VM, keyless local shards)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* perf(next-version): fetch PR VERSIONs concurrently; a timed-out fetch is an unknown claim

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(pregate): gstack remote-gate example and the small-machine pregate row

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pregate): remote gate leaves paid test files to the paid gate, adds reason codes and the keyless backend line

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* 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>

* docs(pregate): remote-gate reason codes, paid-file handling and measured keyed/keyless timing

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pregate): remote gate finishes before pregate's deadline

pregate kills the remote gate at --timeout with a 5 s grace, which could cut a
VM teardown short. pregate-remote.sh now reads the enclosing deadline
(PREGATE_TIMEOUT_S, else the gstack-pregate --timeout it runs under, else 600),
reserves 120 s for teardown plus 15 s margin, and tells the VM to stop at the
earlier of that point and the cap's wall; a deadline that leaves under 300 s
refuses before any VM (PREGATE_REMOTE_TIMEOUT_TOO_SHORT). It names the VM up
front and prints the exact ubi-runner.sh down line, and the body is one braced
block so an edit to the script mid-run cannot change what the run executes
(keyed run 5 died after teardown with '$2: unbound variable' for that reason).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(restamp): an incomplete claim queue is never reported as read

gstack-next-version now returns complete: false with unknown_claims when a
PR's VERSION fetch timed out or failed. restamp's claim-at-open path took any
usable JSON as 'read'; it now says 'incomplete', names the unknown PRs and
calls the version provisional, the same warn-not-refuse stance as the
existing 'unverified' fallback.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pregate): only a gstack-pregate process sets the remote gate's deadline

The ancestor walk matched any command line that mentioned gstack-pregate, so a
shell line running pregate beside the tests (bash -c '...; bin/gstack-pregate
...') gave the stub gate a 600 s deadline. It now matches only a process whose
program, or the script its interpreter runs, is gstack-pregate.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): A3 remote gate, --files-from, concurrent next-version

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.132.0 chore: stamp

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-18 02:13:29 +00:00

884 lines
36 KiB
TypeScript
Executable File

#!/usr/bin/env bun
// gstack-next-version — host-aware VERSION allocator for /ship.
//
// Queries the PR queue (GitHub or GitLab), fetches each open PR's VERSION,
// scans configurable Conductor sibling worktrees, picks the next free version
// slot at the requested bump level, and emits the whole picture as JSON.
//
// 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, and
// `complete` is false (a provisional pick, also said on stderr and in warnings).
//
// Usage:
// gstack-next-version --base <branch> --bump <major|minor|patch|micro> \
// --current-version <X.Y.Z.W> [--workspace-root <path>|null] \
// [--version-path <path>] [--json]
//
// VERSION path resolution (monorepo support):
// 1. --version-path <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)
//
// The pinned path may be a package.json (any depth) rather than a plain-text
// VERSION file: a path ending in .json is read as JSON and its .version taken.
// 3-digit semver is accepted as well as 4-digit, and stays 3-digit through
// bumping. See lib/version-source.ts for why both mattered — each used to fail
// closed, which silently disabled the queue-collision check this CLI exists to
// provide (#2501).
//
// Exit codes:
// 0 — emitted JSON successfully (may include "offline":true or "host":"unknown",
// or "no_version":true with "version":null when no version source is
// configured — see resolveVersionSource in lib/version-source.ts)
// 2 — invalid arguments, or a configured version source that is missing,
// empty, unreadable or malformed
// 3 — util bug (unexpected exception)
import { execFileSync, spawn, spawnSync } from "node:child_process";
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import {
parseVersion,
versionWidth,
fmtVersion,
bumpVersion,
cmpVersion,
bumpWasCoerced,
extractVersion,
noVersionNotice,
resolveVersionRel,
resolveVersionSource,
type Bump,
type Version,
type VersionWidth,
} from "../lib/version-source";
type ClaimedPR = {
pr: number;
branch: string;
version: string;
url?: string;
};
type Sibling = {
path: string;
branch: string;
version: string;
last_commit_ts: number;
has_open_pr: boolean;
is_active: boolean;
};
type Output = {
version: string;
current_version: string;
base_version: string;
version_path: string;
bump: Bump;
host: "github" | "gitlab" | "unknown";
offline: boolean;
fallback: "git" | null;
claimed: ClaimedPR[];
siblings: Sibling[];
active_siblings: Sibling[];
reason: string;
warnings: string[];
unknown_claims: number[];
complete: boolean;
};
const ACTIVE_SIBLING_MAX_AGE_S = 24 * 60 * 60;
const GH_API_CONCURRENCY = 10;
const VERSION_FETCH_TIMEOUT_MS = 15000;
// Collision resolution: bump past the highest claimed within the same level.
// Semantics: if my bump is MINOR and the queue claims 1.7.0.0, I advance to
// 1.8.0.0 (still a MINOR relative to main). Preserves ship-time intent.
// `width` keeps a 3-digit repo 3-digit (see lib/version-source.ts); it
// defaults to 4 so existing callers and tests are unaffected.
function pickNextSlot(base: Version, claimed: Version[], level: Bump, width: VersionWidth = 4): { version: Version; reason: string } {
let candidate = bumpVersion(base, level, width);
const sortedClaimed = [...claimed].sort(cmpVersion);
const highest = sortedClaimed[sortedClaimed.length - 1];
if (highest && cmpVersion(highest, base) > 0) {
// Queue already advanced past base; bump past the highest claim.
const bumpedPastHighest = bumpVersion(highest, level, width);
if (cmpVersion(bumpedPastHighest, candidate) > 0) {
return { version: bumpedPastHighest, reason: `bumped past claimed ${fmtVersion(highest, width)}` };
}
}
return { version: candidate, reason: "no collision; clean bump from base" };
}
function runCommand(cmd: string, args: string[], timeoutMs = 15000): { ok: boolean; stdout: string; stderr: string } {
const r = spawnSync(cmd, args, { encoding: "utf8", timeout: timeoutMs });
return {
ok: r.status === 0 && !r.error,
stdout: r.stdout ?? "",
stderr: r.stderr ?? (r.error ? String(r.error) : ""),
};
}
// Async twin of runCommand for the per-PR VERSION fetches, so GH_API_CONCURRENCY
// workers really overlap. A fetch past timeoutMs is killed and settles at once
// as timedOut, without waiting on output pipes a descendant may still hold.
function runCommandAsync(cmd: string, args: string[], timeoutMs: number): Promise<{ ok: boolean; stdout: string; stderr: string; timedOut: boolean }> {
return new Promise((done) => {
let stdout = "";
let stderr = "";
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
const timer = setTimeout(() => {
child.kill("SIGKILL");
child.stdout.destroy();
child.stderr.destroy();
done({ ok: false, stdout, stderr, timedOut: true });
}, timeoutMs);
child.stdout.on("data", (chunk) => { stdout += chunk; });
child.stderr.on("data", (chunk) => { stderr += chunk; });
child.on("error", (error) => {
clearTimeout(timer);
done({ ok: false, stdout, stderr: stderr || String(error), timedOut: false });
});
child.on("close", (code) => {
clearTimeout(timer);
done({ ok: code === 0, stdout, stderr, timedOut: false });
});
});
}
// 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 {
try {
return resolveVersionRel(repoRoot, override).rel;
} catch {
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) {
const url = remote.stdout.trim();
if (url.includes("github.com")) return "github";
if (url.includes("gitlab")) return "gitlab";
}
const gh = runCommand("gh", ["auth", "status"]);
if (gh.ok) return "github";
const glab = runCommand("glab", ["auth", "status"]);
if (glab.ok) return "gitlab";
return "unknown";
}
// When the base-version read fails we assume a zero base — but a literal
// "0.0.0.0" is 4-digit, which flips versionWidth() to 4 and hands a 3-digit
// repo a 4-digit slot (the exact width class of bug #2501 fixed in parsing).
// The LOCAL version file at versionPath knows the repo's own width; shape the
// zero from it. No local file either → keep the 4-digit default.
function zeroBaseAtLocalWidth(versionPath: string, repoRoot: string): string {
try {
const local = extractVersion(readFileSync(join(repoRoot, versionPath), "utf8"), versionPath);
if (local && parseVersion(local) && versionWidth(local) === 3) return "0.0.0";
} catch {
// unreadable/absent local version file — 4-digit default below
}
return "0.0.0.0";
}
function readBaseVersion(base: string, versionPath: string, repoRoot: string, warnings: string[]): string {
// git fetch is best-effort; we tolerate failure and fall back to whatever
// origin/<base> currently points at.
runCommand("git", ["fetch", "--no-auto-maintenance", "origin", base, "--quiet"], 10000);
const r = runCommand("git", ["show", `origin/${base}:${versionPath}`]);
if (!r.ok) {
const assumed = zeroBaseAtLocalWidth(versionPath, repoRoot);
warnings.push(`could not read ${versionPath} at origin/${base}; assuming ${assumed}`);
return assumed;
}
const v = extractVersion(r.stdout, versionPath);
if (!v) {
const assumed = zeroBaseAtLocalWidth(versionPath, repoRoot);
warnings.push(`${versionPath} at origin/${base} has no readable version; assuming ${assumed}`);
return assumed;
}
return v;
}
async function fetchGithubClaimed(
base: string,
versionPath: string,
excludePR: number | null,
warnings: string[],
fetchTimeoutMs = VERSION_FETCH_TIMEOUT_MS,
): Promise<{ claimed: ClaimedPR[]; offline: boolean; unknownClaims: number[] }> {
const list = runCommand("gh", [
"pr",
"list",
"--state",
"open",
"--base",
base,
"--limit",
"200",
"--json",
"number,headRefName,headRepositoryOwner,url,isDraft",
]);
if (!list.ok) {
warnings.push(`gh pr list failed: ${list.stderr.trim().slice(0, 200)}`);
return { claimed: [], offline: true, unknownClaims: [] };
}
let prs: {
number: number;
headRefName: string;
headRepositoryOwner?: { login: string };
url: string;
isDraft: boolean;
}[];
try {
prs = JSON.parse(list.stdout);
} catch (e) {
warnings.push(`gh pr list returned invalid JSON`);
return { claimed: [], offline: true, unknownClaims: [] };
}
// Determine our repo owner to filter out fork PRs. `gh api contents?ref=<branch>`
// resolves to OUR repo regardless of where the PR originated, so fork PRs would
// otherwise return our main's VERSION as a phantom claim.
const viewer = runCommand("gh", ["repo", "view", "--json", "owner", "-q", ".owner.login"]);
const myOwner = viewer.ok ? viewer.stdout.trim() : "";
const sameRepoPRs = (myOwner
? prs.filter((p) => (p.headRepositoryOwner?.login ?? "") === myOwner)
: prs
).filter((p) => excludePR === null || p.number !== excludePR);
// Fetch each PR's VERSION at its head in parallel (bounded concurrency).
// Workers finish in any order, so claims and warnings are sorted by PR number.
const results: ClaimedPR[] = [];
const prWarnings: { pr: number; text: string }[] = [];
const unknownClaims: number[] = [];
const queue = [...sameRepoPRs];
const workers = Array.from({ length: Math.min(GH_API_CONCURRENCY, sameRepoPRs.length) }, async () => {
while (queue.length) {
const pr = queue.shift();
if (!pr) return;
const warn = (text: string) => prWarnings.push({ pr: pr.number, text: `PR #${pr.number}: ${text}` });
// 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 = await runCommandAsync("gh", [
"api",
`repos/{owner}/{repo}/contents/${encodeURI(versionPath)}?ref=${encodeURIComponent(pr.headRefName)}`,
"-q",
".content",
], fetchTimeoutMs);
if (content.timedOut) {
unknownClaims.push(pr.number);
warn(`VERSION fetch timed out after ${fetchTimeoutMs / 1000} s; claim unknown, the slot may collide; rerun, then ask the coordinator`);
continue;
}
if (!content.ok) {
if (/HTTP 404|Not Found/i.test(content.stderr)) {
warn(`could not fetch ${versionPath} (fork, private, or wrong path — try --version-path or .gstack/version-path)`);
} else {
unknownClaims.push(pr.number);
warn(`VERSION fetch failed (${content.stderr.trim().split("\n")[0]?.slice(0, 200) || "no error output"}); claim unknown, the slot may collide; rerun, then ask the coordinator`);
}
continue;
}
let versionStr: string;
try {
versionStr = extractVersion(Buffer.from(content.stdout.trim(), "base64").toString("utf8"), versionPath);
} catch {
warn("VERSION is not valid base64");
continue;
}
if (!parseVersion(versionStr)) {
warn(`VERSION is malformed (${versionStr})`);
continue;
}
results.push({ pr: pr.number, branch: pr.headRefName, version: versionStr, url: pr.url });
}
});
await Promise.all(workers);
warnings.push(...prWarnings.sort((a, b) => a.pr - b.pr).map((w) => w.text));
return { claimed: results.sort((a, b) => a.pr - b.pr), offline: false, unknownClaims: unknownClaims.sort((a, b) => a - b) };
}
async function fetchGitlabClaimed(base: string, versionPath: string, excludePR: number | null, warnings: string[]): Promise<{ claimed: ClaimedPR[]; offline: boolean }> {
const list = runCommand("glab", [
"mr",
"list",
"--opened",
"--target-branch",
base,
"--output",
"json",
"--per-page",
"200",
]);
if (!list.ok) {
warnings.push(`glab mr list failed: ${list.stderr.trim().slice(0, 200)}`);
return { claimed: [], offline: true };
}
let mrs: { iid: number; source_branch: string; web_url: string }[];
try {
mrs = JSON.parse(list.stdout);
} catch {
warnings.push(`glab mr list returned invalid JSON`);
return { claimed: [], offline: true };
}
if (excludePR !== null) {
mrs = mrs.filter((mr) => mr.iid !== excludePR);
}
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/${encodeURIComponent(versionPath)}?ref=${encodeURIComponent(mr.source_branch)}`,
]);
if (!content.ok) {
warnings.push(
`MR !${mr.iid}: could not fetch ${versionPath} (wrong path? — try --version-path or .gstack/version-path)`,
);
continue;
}
try {
const j = JSON.parse(content.stdout);
const versionStr = extractVersion(Buffer.from(j.content, "base64").toString("utf8"), versionPath);
if (!parseVersion(versionStr)) {
warnings.push(`MR !${mr.iid}: VERSION malformed (${versionStr})`);
continue;
}
results.push({ pr: mr.iid, branch: mr.source_branch, version: versionStr, url: mr.web_url });
} catch {
warnings.push(`MR !${mr.iid}: unexpected glab api response`);
}
}
return { claimed: results, offline: false };
}
function resolveWorkspaceRoot(override?: string): string | null {
if (override === "null") return null;
if (override) return override;
const r = runCommand(join(__dirname, "gstack-config"), ["get", "workspace_root"]);
const configured = r.ok ? r.stdout.trim() : "";
if (configured === "null") return null;
if (configured) return configured;
// Default: $HOME/conductor/workspaces/
return join(homedir(), "conductor", "workspaces");
}
function currentRepoSlug(): string {
const r = runCommand("git", ["remote", "get-url", "origin"]);
if (!r.ok) return "";
// Extract "owner/repo" from URL like git@github.com:owner/repo.git
const m = r.stdout.trim().match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
return m ? m[1] : "";
}
function scanSiblings(root: string | null, versionPath: string, claimed: ClaimedPR[], warnings: string[]): Sibling[] {
if (!root || !existsSync(root)) return [];
const mySlug = currentRepoSlug();
if (!mySlug) {
warnings.push("could not determine current repo slug; skipping sibling scan");
return [];
}
const repoName = mySlug.split("/").pop() ?? "";
// Conductor layout: <root>/<repo>/<workspace>/
const repoDir = join(root, repoName);
if (!existsSync(repoDir)) return [];
const myAbsPath = resolve(process.cwd());
const results: Sibling[] = [];
for (const name of readdirSync(repoDir)) {
const p = join(repoDir, name);
if (resolve(p) === myAbsPath) continue;
try {
const s = statSync(p);
if (!s.isDirectory()) continue;
} catch {
continue;
}
if (!existsSync(join(p, ".git")) && !existsSync(join(p, ".git/HEAD"))) continue;
const versionFile = join(p, versionPath);
if (!existsSync(versionFile)) continue;
let version: string;
try {
version = extractVersion(readFileSync(versionFile, "utf8"), versionPath);
if (!parseVersion(version)) continue;
} catch {
continue;
}
const branchR = runCommand("git", ["-C", p, "rev-parse", "--abbrev-ref", "HEAD"]);
if (!branchR.ok) continue;
const branch = branchR.stdout.trim();
const commitTsR = runCommand("git", ["-C", p, "log", "-1", "--format=%ct"]);
const last_commit_ts = commitTsR.ok ? Number(commitTsR.stdout.trim()) : 0;
const has_open_pr = claimed.some((c) => c.branch === branch);
results.push({
path: p,
branch,
version,
last_commit_ts,
has_open_pr,
is_active: false,
});
}
return results;
}
function markActiveSiblings(siblings: Sibling[], baseVersion: Version): Sibling[] {
const now = Math.floor(Date.now() / 1000);
return siblings.map((s) => {
const v = parseVersion(s.version);
const isAhead = v ? cmpVersion(v, baseVersion) > 0 : false;
const isFresh = s.last_commit_ts > 0 && now - s.last_commit_ts < ACTIVE_SIBLING_MAX_AGE_S;
const is_active = isAhead && isFresh && !s.has_open_pr;
return { ...s, is_active };
});
}
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];
if (a === "--base") base = argv[++i] ?? "";
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;
}
else if (a === "-h" || a === "--help") help = true;
}
if (help) return { base: "", bump: "micro", current: "", excludePR: null, help: true };
if (!base) {
// Detect the default branch instead of assuming main (local-only repos
// on trunk/master work like GitHub repos on main). Same probe order as
// the canonical chain in bin/gstack-diff-scope and {{BASE_BRANCH_DETECT}}
// (scripts/resolvers/utility.ts): origin/HEAD -> origin/main ->
// origin/master -> literal "main". origin/HEAD is unset on plain clones
// that never ran `git remote set-head`, so the rev-parse probes matter.
try {
const head = execFileSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
base = head.replace("refs/remotes/origin/", "");
} catch {
// fall through to the rev-parse probes
}
if (!base) {
for (const candidate of ["main", "master"]) {
try {
execFileSync("git", ["rev-parse", "--verify", "-q", `origin/${candidate}`], { stdio: ["ignore", "ignore", "ignore"] });
base = candidate;
break;
} catch {
// probe failed; try the next candidate
}
}
}
if (!base) base = "main";
}
if (!bump) {
console.error("Error: --bump is required (major|minor|patch|micro)");
process.exit(2);
}
if (!["major", "minor", "patch", "micro"].includes(bump)) {
console.error(`Error: --bump must be major|minor|patch|micro (got ${bump})`);
process.exit(2);
}
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
// already has an open PR and exclude it by default. This prevents the self-
// reference bug where /ship's own PR inflates the queue on rerun.
function autoDetectExcludePR(): number | null {
const r = runCommand("gh", ["pr", "view", "--json", "number", "-q", ".number"]);
if (!r.ok) return null;
const n = Number(r.stdout.trim());
return Number.isFinite(n) && n > 0 ? n : null;
}
// ── git-only fallback (#2545) ────────────────────────────────────────────
//
// When the host query fails this util used to return `offline:true` with an
// EMPTY claim set, and /ship's instruction was "fall back to local BUMP_LEVEL
// arithmetic". Local arithmetic cannot see a sibling's claim, so the fallback
// allocated a version another open PR already held.
//
// That is not hypothetical. On 2026-08-12 in a downstream repo, `gh pr list`
// failed during a ship, this util reported offline, the bump fell back to
// local arithmetic, and 0.1.57.0 was allocated to a second PR while an open
// one already claimed it — both merged, and main carries two commits reading
// v0.1.57.0. Auditing that repo's history found FOUR such pairs going back
// three weeks, so the silent fallback had been mis-allocating for a while.
//
// Git already knows what the API was asked for. `git ls-remote --heads origin`
// returns the remote's LIVE branch list with zero local mutation (no fetch, no
// ref updates), each branch's VERSION file is readable from the local object
// store, and the base's own history records every version already shipped.
// None of it needs a token or a working `gh`. So "offline" degrades the QUEUE
// VIEW (no PR numbers, no draft status) without degrading the ALLOCATION.
function fetchGitClaimed(
base: string,
versionPath: string,
warnings: string[],
): ClaimedPR[] {
const claims: ClaimedPR[] = [];
const baseShort = base.replace(/^origin\//, "");
// 1. The version-claim branches. FIRST try `git ls-remote --heads origin`:
// fresh remote data, zero local mutation. This scopes claims to branches
// that actually EXIST on origin right now — the previous implementation
// counted every remote-tracking ref on EVERY remote, so stale local refs
// (deleted PR branches, an unrelated `upstream` remote) inflated the
// claim set and pushed the allocation further than the real queue.
// GIT_TERMINAL_PROMPT=0 + a 5s timeout keep a dead/credential-prompting
// remote from hanging the allocator.
// Guard: only consult ls-remote when a remote named `origin` is actually
// CONFIGURED. Without the guard, git DWIMs the literal `origin` as an
// ssh host/path — and on hosts whose ssh launders exit codes (observed:
// Vercel sandboxes' ssh exits 0 on "Could not resolve hostname"), the
// probe "succeeds" with zero branches and the allocator silently sees an
// empty queue: the exact duplicate-allocation failure (#2545) this
// function exists to prevent.
const originConfigured =
spawnSync("git", ["remote", "get-url", "origin"], {
encoding: "utf8",
timeout: 5000,
}).status === 0;
const lsRemote = originConfigured
? spawnSync("git", ["ls-remote", "--heads", "origin"], {
encoding: "utf8",
timeout: 5000,
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
})
: null;
const lsOk = lsRemote !== null && lsRemote.status === 0 && !lsRemote.error;
// A configured origin that "successfully" advertises ZERO heads is a
// contradiction — every reachable remote advertises at least its default
// branch. It means the exit code was laundered (observed: the Conductor
// /conductor/bin/git shim exits 0 on every network failure), so an exit-0
// empty probe must NOT be believed as "the queue is empty" — that is the
// duplicate-allocation bug (#2545) again, one layer up from the
// originConfigured guard. Parse first; trust only a non-empty head list.
const parsedHeads = lsOk
? (lsRemote.stdout ?? "")
.split("\n")
.map((line) => line.trim().match(/^([0-9a-f]{40,64})\trefs\/heads\/(.+)$/))
.filter((m): m is RegExpMatchArray => m !== null)
: [];
const lsTrusted = lsOk && parsedHeads.length > 0;
// Each candidate carries the LIVE tip sha when it came from ls-remote, so
// the VERSION read prefers the fresh commit (present locally after any
// prior fetch/clone) and only falls back to the local remote-tracking ref.
const candidates: { branch: string; sha?: string }[] = [];
if (lsTrusted) {
for (const m of parsedHeads) {
if (m[2] === baseShort) continue;
candidates.push({ branch: m[2], sha: m[1] });
}
} else {
// Degraded twice over: no host API AND no reachable remote. Fall back to
// the LOCAL refs/remotes/origin snapshot ONLY (never other remotes — an
// `upstream` remote's branches are not claims against OUR queue).
warnings.push(
lsOk
? "git ls-remote origin exited 0 but advertised zero heads (an empty remote, or an exit-code-laundering git shim); " +
"using stale local refs/remotes/origin — branches deleted on the remote may still be counted as claims (run `git fetch --prune origin` to refresh)"
: "git ls-remote origin failed; using stale local refs/remotes/origin — " +
"branches deleted on the remote may still be counted as claims (run `git fetch --prune origin` to refresh)",
);
const refs = runCommand("git", [
"for-each-ref",
"--format=%(refname:short)",
"refs/remotes/origin",
]);
if (refs.ok) {
for (const ref of refs.stdout.split("\n").map((r) => r.trim()).filter(Boolean)) {
if (ref.endsWith("/HEAD")) continue;
const branch = ref.replace(/^origin\//, "");
if (branch === baseShort) continue;
candidates.push({ branch });
}
} else {
warnings.push("git for-each-ref failed; branch claims unavailable");
}
}
// Read each candidate's VERSION through extractVersion so a JSON
// version-path (#2501) resolves on remote refs too, and the branch's own
// width is preserved in the claim. Reads are LOCAL-first; branches whose
// advertised tip has no local object are collected and resolved with ONE
// batched shallow fetch below. A per-branch fetch loop here once crawled
// a busy remote for minutes on a shallow CI clone (dozens of sequential
// network fetches, 10s cap each) — the total network budget must be one
// bounded round trip regardless of branch count.
const readClaim = (branch: string, sha?: string): "claimed" | "not-a-claim" | "object-missing" => {
let show = sha ? runCommand("git", ["show", `${sha}:${versionPath}`]) : { ok: false, stdout: "", stderr: "" };
if (!show.ok) {
// Live tip not fetched yet (or no sha in the fallback path): best-effort
// read from the local remote-tracking ref.
show = runCommand("git", ["show", `refs/remotes/origin/${branch}:${versionPath}`]);
}
if (!show.ok) {
if (!sha) return "not-a-claim";
// Distinguish "object missing" from "branch has no VERSION file".
return runCommand("git", ["cat-file", "-e", sha]).ok ? "not-a-claim" : "object-missing";
}
const raw = extractVersion(show.stdout, versionPath);
if (!raw || !parseVersion(raw)) return "not-a-claim";
claims.push({ pr: 0, branch: `origin/${branch}`, version: raw });
return "claimed";
};
const pending: { branch: string; sha?: string }[] = [];
for (const { branch, sha } of candidates) {
if (readClaim(branch, sha) === "object-missing") pending.push({ branch, sha });
}
if (pending.length > 0) {
// ls-remote advertises SHAs without objects: a branch pushed after our
// last fetch has NO local object. The pre-#2545 `continue` silently
// dropped a LIVE claim — the exact duplicate-allocation this fallback
// exists to prevent. One shallow batched fetch (no prompts, no tags,
// bounded) brings every missing tip local in a single round trip.
spawnSync(
"git",
["fetch", "--no-auto-maintenance", "origin", ...pending.map((p) => `refs/heads/${p.branch}`), "--depth=1", "--no-tags"],
{ encoding: "utf8", timeout: 15000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } },
);
// One unservable ref (dangling sha on the server) fails the WHOLE batch
// transfer, so refs still missing get a bounded per-branch retry — that
// isolates a poisoned ref without reopening the unbounded fetch crawl
// (per-branch-only fetching once ground a shallow CI clone against a
// busy remote for minutes). Anything past the cap is warned, not fetched.
const RETRY_CAP = 8;
let retries = 0;
for (const { branch, sha } of pending) {
let outcome = readClaim(branch, sha);
if (outcome === "object-missing" && retries < RETRY_CAP) {
retries++;
spawnSync(
"git",
["fetch", "--no-auto-maintenance", "origin", `refs/heads/${branch}`, "--depth=1", "--no-tags"],
{ encoding: "utf8", timeout: 5000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } },
);
outcome = readClaim(branch, sha);
}
if (outcome === "object-missing") {
// STILL unreadable (fetch failed, retry cap hit, or the tip moved
// between ls-remote and fetch) — never skip silently. Surface it as
// an UNKNOWN claim so the caller knows the allocation may be unsafe.
warnings.push(
`origin/${branch}: VERSION unreadable even after a targeted fetch — ` +
`counted as an UNKNOWN claim; allocation may collide with this branch. ` +
`Run \`git fetch origin ${branch}\` and re-run to verify.`,
);
}
}
}
// 2. Versions already shipped, read from the base's commit subjects. Catches
// the case the VERSION file cannot: a number that merged and was then
// re-picked. Bounded, and it says so rather than implying full history.
const SUBJECT_SCAN = 400;
const log = runCommand("git", ["log", `-n${SUBJECT_SCAN}`, "--format=%s", base]);
if (log.ok) {
for (const subject of log.stdout.split("\n")) {
const m = subject.trim().match(/^v(\d+\.\d+\.\d+(?:\.\d+)?)\b/);
if (!m) continue;
if (!parseVersion(m[1])) continue;
claims.push({ pr: 0, branch: `(shipped on ${base})`, version: m[1] });
}
// A cap that does not announce itself reads as "checked all history".
// Only fires when the log came back exactly full, which is the only
// observable signal that older commits went unread.
if (log.stdout.trim().split("\n").length >= SUBJECT_SCAN) {
warnings.push(
`shipped-version scan stopped at ${SUBJECT_SCAN} commits on ${base}; ` +
`a version shipped before that is not counted as claimed`,
);
}
} else {
warnings.push(`git log ${base} failed; shipped-version scan unavailable`);
}
return claims;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(
"Usage: gstack-next-version --base <branch> --bump <level> --current-version <X.Y.Z.W> [--workspace-root <path|null>] [--version-path <path>]",
);
process.exit(0);
}
const warnings: string[] = [];
const host = detectHost();
const repoRoot = repoToplevel();
// One no-version signal (#2334, #2343): the same four-outcome resolution
// gstack-version-bump uses. No configured source → report no_version and
// allocate nothing; a broken configured source → stop with the reason.
const src = resolveVersionSource(repoRoot, args.versionPath);
if (src.outcome === "broken") {
console.error(`Error: version source is broken: ${src.reason}. Fix that file or its pin (gstack never substitutes 0.0.0.0).`);
process.exit(2);
}
if (src.outcome !== "valid") {
process.stdout.write(
JSON.stringify(
{
version: null,
no_version: true,
version_source: { outcome: src.outcome, path: src.path, reason: src.reason },
notice: noVersionNotice(src),
bump: args.bump,
warnings,
},
null,
2,
) + "\n",
);
return;
}
const versionPath = src.path!;
const baseVersion = args.current || readBaseVersion(args.base, versionPath, repoRoot, warnings);
const baseParsed = parseVersion(baseVersion);
if (!baseParsed) {
console.error(`Error: could not parse base version '${baseVersion}'`);
process.exit(2);
}
// The repo's own width governs everything downstream: a 3-digit repo must
// not be handed a 4-digit slot, or /ship writes a version the repo's tooling
// can't read back (#2501).
const width = versionWidth(baseVersion);
if (bumpWasCoerced(args.bump, width)) {
warnings.push(`--bump micro has no component to move in a ${width}-digit version; treated as patch`);
}
const excludePR = args.excludePR ?? autoDetectExcludePR();
if (excludePR !== null && args.excludePR === null) {
warnings.push(`auto-excluded PR #${excludePR} (current branch's own PR)`);
}
let claimed: ClaimedPR[] = [];
let offline = false;
let unknownClaims: number[] = [];
if (host === "github") {
({ claimed, offline, unknownClaims } = await fetchGithubClaimed(args.base, versionPath, excludePR, warnings));
} else if (host === "gitlab") {
({ claimed, offline } = await fetchGitlabClaimed(args.base, versionPath, excludePR, warnings));
} else {
warnings.push("host unknown; queue-awareness unavailable");
}
// Degraded host query → fall back to git, which needs no API. Additive: it
// only runs when the host told us nothing, so the online path is untouched.
let fallback: "git" | null = null;
if (offline || host === "unknown") {
const gitClaims = fetchGitClaimed(args.base, versionPath, warnings);
if (gitClaims.length) {
claimed = [...claimed, ...gitClaims];
fallback = "git";
warnings.push(
`host queue unavailable — allocated from git instead ` +
`(${gitClaims.length} claim(s) from remote refs + shipped subjects). ` +
`PR numbers and draft status are unavailable, but the version is safe.`,
);
} else {
warnings.push(
"host queue unavailable AND git found no claims — the pick rests on " +
"the base VERSION alone. Verify no sibling branch holds it before " +
"shipping.",
);
}
}
// Only count PRs that actually bumped VERSION past base as real "claims".
// A PR whose VERSION equals base's VERSION hasn't claimed anything.
const realClaims = claimed.filter((c) => {
const v = parseVersion(c.version);
return v !== null && cmpVersion(v, baseParsed) > 0;
});
const claimedVersions = realClaims
.map((c) => parseVersion(c.version))
.filter((v): v is Version => v !== null);
const { version: picked, reason } = pickNextSlot(baseParsed, claimedVersions, args.bump, width);
const workspaceRoot = resolveWorkspaceRoot(args.workspaceRoot);
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).
let finalVersion = picked;
let finalReason = reason;
const activeAhead = activeSiblings
.map((s) => parseVersion(s.version))
.filter((v): v is Version => v !== null)
.filter((v) => cmpVersion(v, finalVersion) >= 0);
if (activeAhead.length) {
const highest = activeAhead.sort(cmpVersion)[activeAhead.length - 1];
finalVersion = bumpVersion(highest, args.bump, width);
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,
base_version: baseVersion,
version_path: versionPath,
bump: args.bump,
host,
offline,
fallback,
claimed: realClaims,
siblings,
active_siblings: activeSiblings,
reason: finalReason,
warnings,
unknown_claims: unknownClaims,
complete,
};
process.stdout.write(JSON.stringify(out, null, 2) + "\n");
}
// Pure-function exports for testing. The version primitives are re-exported
// from lib/version-source so existing importers of this module keep working
// unchanged.
export { parseVersion, fmtVersion, bumpVersion, cmpVersion, versionWidth, extractVersion };
export { pickNextSlot, markActiveSiblings, resolveVersionPath, fetchGitClaimed, fetchGithubClaimed, VERSION_FETCH_TIMEOUT_MS };
// Only run main() when invoked as a script, not when imported by tests.
if (import.meta.main) {
main().catch((e) => {
console.error("Unexpected error:", e?.stack ?? e);
process.exit(3);
});
}