mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-01 19:25:10 +02:00
v1.11.0.0 feat(ship): workspace-aware version allocation (#1168)
* feat: bin/gstack-next-version util + workspace_root config key Host-aware (GitHub + GitLab + unknown) VERSION allocator. Queries the open PR queue, fetches each PR's VERSION at head, scans configurable Conductor sibling worktrees for WIP work, and picks the next free slot at the requested bump level. Pure reader, never writes files. /ship consumes the JSON and decides. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: fixture tests for gstack-next-version 21 pure-function tests covering parseVersion / bumpVersion / cmpVersion / pickNextSlot (with 8 collision scenarios) / markActiveSiblings (4 cases) plus one CLI smoke test against the live repo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(scripts): detect-bump + compare-pr-version helpers Shared between /ship (legacy path) and the CI version-gate job. detect-bump: derive bump level from VERSION diff. compare-pr-version: CI gate logic with three exit paths (pass / block / fail-open). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ci): version-gate + pr-title-sync workflows (GitHub + GitLab) Merge-time collision gate. Fail-open on util errors (network, auth, bug), fail-closed on confirmed collisions. pr-title-sync rewrites the PR title when VERSION changes on push, only for titles that already carry the v<X.Y.Z.W> prefix (custom titles left alone). GitLab CI mirrors both jobs for host parity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(skills): queue-aware /ship + drift abort in /land-and-deploy + advisory in /review ship Step 12: queue-aware version pick (FRESH path) + drift detection (ALREADY_BUMPED path). Prompts user to rebump when queue moved, runs the full ship metadata path (VERSION, package.json, CHANGELOG header, PR title) on the rebump so nothing goes stale. ship Step 19: PR title format v<X.Y.Z.W> <type>: <summary> — version ALWAYS first. Rerun path updates title (not just body) when VERSION changed. land-and-deploy Step 3.4: detect drift, ABORT with instruction to rerun /ship. Never auto-mutates from land. review Step 3.4: advisory one-line queue status. Non-blocking. Goldens refreshed for all three hosts (claude/codex/factory). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(skill): /landing-report read-only queue dashboard Standalone skill that renders the current PR queue, sibling worktrees, and what all four bump levels would claim. Pure reader. Useful when running many parallel Conductor workspaces to see what's in flight before shipping anything. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: versioning invariant in CLAUDE.md Document that VERSION is a monotonic sequence, not a strict semver commitment. Bump level expresses intent; queue-advance within a level is permitted. Prevents future re-litigation of the workspace-aware ship design. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v1.8.0.0) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ship): exclude current PR from queue-awareness (self-reference bug) Version gate flagged PR #1168 as stale because the util counted the PR itself as a queued claim. The exclude filter removes that self-reference. New --exclude-pr <N> flag on bin/gstack-next-version. CI workflows pass github.event.pull_request.number / CI_MERGE_REQUEST_IID. Local /ship auto-detects via gh pr view when the flag isn't passed, with a warning recording the auto-exclusion so it's observable. Caught during the first live ship through the v1.8.0.0 gate — the kind of dogfood the whole release is designed for. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Merge remote-tracking branch 'origin/main' into garrytan/workspace-aware-ship Rebumped v1.8.0.0 -> v1.11.0.0 (minor-past main's v1.10.1.0) using bin/gstack-next-version — the same queue-aware path this branch introduces. CHANGELOG repositioned so v1.11.0.0 sits above main's new entries (v1.10.1.0 / v1.10.0.0 / v1.9.0.0). Conflicts resolved: - VERSION, package.json: rebumped to v1.11.0.0 (util-picked) - bin/gstack-config: merged both lists (workspace_root + gbrain keys) - CHANGELOG.md: hoisted v1.11.0.0 entry above main's new entries Pre-existing failures in main (4) documented but not fixed in this PR: 1. gstack-brain-sync secret scan > blocks bearer-json (brain-sync tests) 2. no files larger than 2MB (security-bench fixture, already TODO'd) 3. selectTests > skill-specific change (touchfiles scoping) 4. Opus 4.7 overlay pacing directive (expectation stale after v1.10.1.0 removed the Fan out nudge) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: re-trigger PR workflows after merge --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bun
|
||||
// compare-pr-version — CI gate helper. Compares the util's next-slot output
|
||||
// against the PR's branch VERSION. Exits 0 (pass), 1 (confirmed collision),
|
||||
// or 2 (util was offline — fail-open per user decision, exit 0 with warning).
|
||||
//
|
||||
// Input:
|
||||
// argv[2] — path to next.json (the util's JSON output)
|
||||
// argv[3] — optional PR number for log lines
|
||||
//
|
||||
// Design note: fail-open on util error. A gstack bug must never freeze the
|
||||
// merge queue. Confirmed collisions (util OK, PR version < next slot) DO block.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const [, , jsonPath, prNumber] = process.argv;
|
||||
if (!jsonPath) {
|
||||
console.error("Usage: compare-pr-version <next.json> [pr-number]");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(jsonPath, "utf8"));
|
||||
} catch (e) {
|
||||
console.log("::warning::could not parse util output; failing open");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (parsed.offline === true) {
|
||||
console.log("::warning::workspace-aware-ship util offline; failing open (no collision check performed)");
|
||||
console.log(`::notice::If you merge this PR and a queued PR landed ahead, CHANGELOG may need manual reconciliation.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// PR_VERSION is supplied via env (set by the workflow from `cat VERSION`).
|
||||
const prVersion = (process.env.PR_VERSION ?? "").trim();
|
||||
const nextSlot = parsed.version;
|
||||
|
||||
if (!prVersion) {
|
||||
console.log("::warning::PR_VERSION not set; failing open");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Parse versions for comparison.
|
||||
function parseV(s: string): number[] | null {
|
||||
const m = s.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])] : null;
|
||||
}
|
||||
function cmp(a: number[], b: number[]): number {
|
||||
for (let i = 0; i < 4; i++) if (a[i] !== b[i]) return a[i] - b[i];
|
||||
return 0;
|
||||
}
|
||||
const pPR = parseV(prVersion);
|
||||
const pNext = parseV(nextSlot);
|
||||
if (!pPR || !pNext) {
|
||||
console.log(`::warning::malformed version string (PR=${prVersion}, next=${nextSlot}); failing open`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const tag = prNumber ? `PR #${prNumber}` : "this PR";
|
||||
|
||||
// Emit a GitHub step summary (always helpful, even on pass).
|
||||
const claimedList = (parsed.claimed ?? [])
|
||||
.map((c: any) => ` #${c.pr} ${c.branch} → v${c.version}`)
|
||||
.join("\n");
|
||||
|
||||
console.log(`::group::Version gate (${tag})`);
|
||||
console.log(` PR VERSION: v${prVersion}`);
|
||||
console.log(` Next slot: v${nextSlot}`);
|
||||
console.log(` Queue (${(parsed.claimed ?? []).length} open PRs claiming versions):`);
|
||||
if (claimedList) console.log(claimedList);
|
||||
console.log("::endgroup::");
|
||||
|
||||
if (cmp(pPR, pNext) >= 0) {
|
||||
console.log(`✓ ${tag} claims v${prVersion} — slot is free (next would be v${nextSlot}).`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Confirmed collision: PR version is stale.
|
||||
console.log(`::error::VERSION drift: ${tag} claims v${prVersion} but the queue has moved — next free slot is v${nextSlot}.`);
|
||||
console.log(`::error::Rerun /ship from the feature branch to reconcile. /ship's ALREADY_BUMPED branch handles this atomically (VERSION, package.json, CHANGELOG, PR title).`);
|
||||
process.exit(1);
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bun
|
||||
// detect-bump — crude heuristic for picking a bump level from a VERSION pair.
|
||||
// Used by CI's version-gate job to re-run the util with the "same" level that
|
||||
// /ship used, without needing persisted bump-intent.
|
||||
//
|
||||
// Input: two VERSION strings via argv: current (base) and target (branch).
|
||||
// Output: a single word: major|minor|patch|micro
|
||||
//
|
||||
// Heuristic: compare slot-by-slot. The first slot that differs IS the level.
|
||||
// If nothing differs (shouldn't happen when called by CI gate — the whole point
|
||||
// is the branch bumped VERSION), default to "patch".
|
||||
|
||||
function detect(a: string, b: string): string {
|
||||
const pa = a.trim().match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
||||
const pb = b.trim().match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (!pa || !pb) return "patch";
|
||||
const [, a1, a2, a3, a4] = pa;
|
||||
const [, b1, b2, b3, b4] = pb;
|
||||
if (a1 !== b1) return "major";
|
||||
if (a2 !== b2) return "minor";
|
||||
if (a3 !== b3) return "patch";
|
||||
if (a4 !== b4) return "micro";
|
||||
return "patch";
|
||||
}
|
||||
|
||||
const [, , base, target] = process.argv;
|
||||
if (!base || !target) {
|
||||
console.error("Usage: detect-bump <base-version> <branch-version>");
|
||||
process.exit(2);
|
||||
}
|
||||
console.log(detect(base, target));
|
||||
Reference in New Issue
Block a user