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:
Garry Tan
2026-04-23 23:03:27 -07:00
committed by GitHub
parent e3d7f49c74
commit e4041f7a7f
24 changed files with 2728 additions and 46 deletions
+36 -8
View File
@@ -2736,8 +2736,8 @@ fi
Read the `STATE:` line and dispatch:
- **FRESH** → proceed with the bump action below (steps 14).
- **ALREADY_BUMPED** → skip the bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. Continue to the next step.
- **DRIFT_STALE_PKG** → a prior `/ship` bumped `VERSION` but failed to update `package.json`. Run the sync-only repair block below (after step 4). Do NOT re-bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body.
- **ALREADY_BUMPED** → skip the bump by default, BUT check for queue drift first: call `bin/gstack-next-version` with the implied bump level (derived from `CURRENT_VERSION` vs `BASE_VERSION`), compare its `.version` against `CURRENT_VERSION`. If they differ (queue moved since last ship), use **AskUserQuestion**: "VERSION drift detected: you claim v<CURRENT> but next available is v<NEW> (queue moved). A) Rebump to v<NEW> and rewrite CHANGELOG header + PR title (recommended), B) Keep v<CURRENT> — will be rejected by CI version-gate until resolved." If A, treat this as FRESH with `NEW_VERSION=<new>` and run steps 1-4 (which will also trigger Step 13 CHANGELOG header rewrite and Step 19 PR title rewrite). If B, reuse `CURRENT_VERSION` and warn that CI will likely reject. If util is offline, warn and reuse `CURRENT_VERSION`.
- **DRIFT_STALE_PKG** → a prior `/ship` bumped `VERSION` but failed to update `package.json`. Run the sync-only repair block below (after step 4). Do NOT re-bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. (Queue check still runs in ALREADY_BUMPED terms after repair.)
- **DRIFT_UNEXPECTED** → `/ship` has halted (exit 1). Resolve manually; /ship cannot tell which file is authoritative.
1. Read the current `VERSION` file (4-digit format: `MAJOR.MINOR.PATCH.MICRO`)
@@ -2750,9 +2750,33 @@ Read the `STATE:` line and dispatch:
- **MINOR** (2nd digit): **ASK the user** if ANY feature signal is detected, OR 500+ lines changed, OR new modules/packages added
- **MAJOR** (1st digit): **ASK the user** — only for milestones or breaking changes
3. Compute the new version:
- Bumping a digit resets all digits to its right to 0
- Example: `0.19.1.0` + PATCH → `0.19.2.0`
Save the chosen level as `BUMP_LEVEL` (one of `major`, `minor`, `patch`, `micro`). This is the user-intended level. The next step decides *placement* — the level stays the same even if queue-aware allocation has to advance past a claimed slot.
3. **Queue-aware version pick (workspace-aware ship, v1.6.4.0+).** Call `bin/gstack-next-version` to see what's already claimed by open PRs + active sibling Conductor worktrees, then render the queue state to the user:
```bash
QUEUE_JSON=$(bun run bin/gstack-next-version \
--base <base> \
--bump "$BUMP_LEVEL" \
--current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
NEW_VERSION=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
CLAIMED_COUNT=$(echo "$QUEUE_JSON" | jq -r '.claimed | length')
ACTIVE_SIBLING_COUNT=$(echo "$QUEUE_JSON" | jq -r '.active_siblings | length')
OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false')
REASON=$(echo "$QUEUE_JSON" | jq -r '.reason // ""')
```
- If `OFFLINE=true` or the util fails (auth expired, no `gh`/`glab`, network): fall back to local `BUMP_LEVEL` arithmetic (bump `BASE_VERSION` at the chosen level). Print `⚠ workspace-aware ship offline — using local bump only`. Continue.
- If `CLAIMED_COUNT > 0`: render the queue table to the user so they can see landing order at a glance:
```
Queue on <base> (vBASE_VERSION):
#<pr> <branch> → v<version> [⚠ collision with #<other>]
Active sibling workspaces (WIP, not yet PR'd):
<path> → v<version> (committed Nh ago)
Your branch will claim: vNEW_VERSION (<reason>)
```
- If `ACTIVE_SIBLING_COUNT > 0` and any active sibling's VERSION is `>= NEW_VERSION`, use **AskUserQuestion**: "Sibling workspace <path> has v<X> committed <N>h ago but hasn't PR'd yet. Wait for them to ship first, or advance past? A) Advance past (recommended for unrelated work), B) Abort /ship and sync up with sibling first."
- Validate `NEW_VERSION` matches `MAJOR.MINOR.PATCH.MICRO`. If util returns an empty or malformed version, fall back to local bump.
4. **Validate** `NEW_VERSION` and write it to **both** `VERSION` and `package.json`. This block runs only when `STATE: FRESH`.
@@ -3093,7 +3117,11 @@ gh pr view --json url,number,state -q 'if .state == "OPEN" then "PR #\(.number):
glab mr view -F json 2>/dev/null | jq -r 'if .state == "opened" then "MR_EXISTS" else "NO_MR" end' 2>/dev/null || echo "NO_MR"
```
If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body "..."` (GitHub) or `glab mr update -d "..."` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run. Print the existing URL and continue to Step 20.
If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body "..."` (GitHub) or `glab mr update -d "..."` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run.
**Also update the PR title** if the version changed on rerun. PR titles use the workspace-aware format `v<NEW_VERSION> <type>: <summary>` — version ALWAYS first. If the current title's version prefix doesn't match `NEW_VERSION`, run `gh pr edit --title "v$NEW_VERSION <type>: <summary>"` (or the `glab mr update -t ...` equivalent). This keeps the title truthful when Step 12's queue-drift detection rebumps a stale version. If the title has no `v<X.Y.Z.W>` prefix (a custom title kept intentionally), leave the title alone — only rewrite titles that already follow the format.
Print the existing URL and continue to Step 20.
If no PR/MR exists: create a pull request (GitHub) or merge request (GitLab) using the platform detected in Step 0.
@@ -3161,7 +3189,7 @@ you missed it.>
**If GitHub:**
```bash
gh pr create --base <base> --title "<type>: <summary>" --body "$(cat <<'EOF'
gh pr create --base <base> --title "v$NEW_VERSION <type>: <summary>" --body "$(cat <<'EOF'
<PR body from above>
EOF
)"
@@ -3170,7 +3198,7 @@ EOF
**If GitLab:**
```bash
glab mr create -b <base> -t "<type>: <summary>" -d "$(cat <<'EOF'
glab mr create -b <base> -t "v$NEW_VERSION <type>: <summary>" -d "$(cat <<'EOF'
<MR body from above>
EOF
)"
+36 -8
View File
@@ -2351,8 +2351,8 @@ fi
Read the `STATE:` line and dispatch:
- **FRESH** → proceed with the bump action below (steps 14).
- **ALREADY_BUMPED** → skip the bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. Continue to the next step.
- **DRIFT_STALE_PKG** → a prior `/ship` bumped `VERSION` but failed to update `package.json`. Run the sync-only repair block below (after step 4). Do NOT re-bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body.
- **ALREADY_BUMPED** → skip the bump by default, BUT check for queue drift first: call `bin/gstack-next-version` with the implied bump level (derived from `CURRENT_VERSION` vs `BASE_VERSION`), compare its `.version` against `CURRENT_VERSION`. If they differ (queue moved since last ship), use **AskUserQuestion**: "VERSION drift detected: you claim v<CURRENT> but next available is v<NEW> (queue moved). A) Rebump to v<NEW> and rewrite CHANGELOG header + PR title (recommended), B) Keep v<CURRENT> — will be rejected by CI version-gate until resolved." If A, treat this as FRESH with `NEW_VERSION=<new>` and run steps 1-4 (which will also trigger Step 13 CHANGELOG header rewrite and Step 19 PR title rewrite). If B, reuse `CURRENT_VERSION` and warn that CI will likely reject. If util is offline, warn and reuse `CURRENT_VERSION`.
- **DRIFT_STALE_PKG** → a prior `/ship` bumped `VERSION` but failed to update `package.json`. Run the sync-only repair block below (after step 4). Do NOT re-bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. (Queue check still runs in ALREADY_BUMPED terms after repair.)
- **DRIFT_UNEXPECTED** → `/ship` has halted (exit 1). Resolve manually; /ship cannot tell which file is authoritative.
1. Read the current `VERSION` file (4-digit format: `MAJOR.MINOR.PATCH.MICRO`)
@@ -2365,9 +2365,33 @@ Read the `STATE:` line and dispatch:
- **MINOR** (2nd digit): **ASK the user** if ANY feature signal is detected, OR 500+ lines changed, OR new modules/packages added
- **MAJOR** (1st digit): **ASK the user** — only for milestones or breaking changes
3. Compute the new version:
- Bumping a digit resets all digits to its right to 0
- Example: `0.19.1.0` + PATCH → `0.19.2.0`
Save the chosen level as `BUMP_LEVEL` (one of `major`, `minor`, `patch`, `micro`). This is the user-intended level. The next step decides *placement* — the level stays the same even if queue-aware allocation has to advance past a claimed slot.
3. **Queue-aware version pick (workspace-aware ship, v1.6.4.0+).** Call `bin/gstack-next-version` to see what's already claimed by open PRs + active sibling Conductor worktrees, then render the queue state to the user:
```bash
QUEUE_JSON=$(bun run bin/gstack-next-version \
--base <base> \
--bump "$BUMP_LEVEL" \
--current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
NEW_VERSION=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
CLAIMED_COUNT=$(echo "$QUEUE_JSON" | jq -r '.claimed | length')
ACTIVE_SIBLING_COUNT=$(echo "$QUEUE_JSON" | jq -r '.active_siblings | length')
OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false')
REASON=$(echo "$QUEUE_JSON" | jq -r '.reason // ""')
```
- If `OFFLINE=true` or the util fails (auth expired, no `gh`/`glab`, network): fall back to local `BUMP_LEVEL` arithmetic (bump `BASE_VERSION` at the chosen level). Print `⚠ workspace-aware ship offline — using local bump only`. Continue.
- If `CLAIMED_COUNT > 0`: render the queue table to the user so they can see landing order at a glance:
```
Queue on <base> (vBASE_VERSION):
#<pr> <branch> → v<version> [⚠ collision with #<other>]
Active sibling workspaces (WIP, not yet PR'd):
<path> → v<version> (committed Nh ago)
Your branch will claim: vNEW_VERSION (<reason>)
```
- If `ACTIVE_SIBLING_COUNT > 0` and any active sibling's VERSION is `>= NEW_VERSION`, use **AskUserQuestion**: "Sibling workspace <path> has v<X> committed <N>h ago but hasn't PR'd yet. Wait for them to ship first, or advance past? A) Advance past (recommended for unrelated work), B) Abort /ship and sync up with sibling first."
- Validate `NEW_VERSION` matches `MAJOR.MINOR.PATCH.MICRO`. If util returns an empty or malformed version, fall back to local bump.
4. **Validate** `NEW_VERSION` and write it to **both** `VERSION` and `package.json`. This block runs only when `STATE: FRESH`.
@@ -2708,7 +2732,11 @@ gh pr view --json url,number,state -q 'if .state == "OPEN" then "PR #\(.number):
glab mr view -F json 2>/dev/null | jq -r 'if .state == "opened" then "MR_EXISTS" else "NO_MR" end' 2>/dev/null || echo "NO_MR"
```
If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body "..."` (GitHub) or `glab mr update -d "..."` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run. Print the existing URL and continue to Step 20.
If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body "..."` (GitHub) or `glab mr update -d "..."` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run.
**Also update the PR title** if the version changed on rerun. PR titles use the workspace-aware format `v<NEW_VERSION> <type>: <summary>` — version ALWAYS first. If the current title's version prefix doesn't match `NEW_VERSION`, run `gh pr edit --title "v$NEW_VERSION <type>: <summary>"` (or the `glab mr update -t ...` equivalent). This keeps the title truthful when Step 12's queue-drift detection rebumps a stale version. If the title has no `v<X.Y.Z.W>` prefix (a custom title kept intentionally), leave the title alone — only rewrite titles that already follow the format.
Print the existing URL and continue to Step 20.
If no PR/MR exists: create a pull request (GitHub) or merge request (GitLab) using the platform detected in Step 0.
@@ -2776,7 +2804,7 @@ you missed it.>
**If GitHub:**
```bash
gh pr create --base <base> --title "<type>: <summary>" --body "$(cat <<'EOF'
gh pr create --base <base> --title "v$NEW_VERSION <type>: <summary>" --body "$(cat <<'EOF'
<PR body from above>
EOF
)"
@@ -2785,7 +2813,7 @@ EOF
**If GitLab:**
```bash
glab mr create -b <base> -t "<type>: <summary>" -d "$(cat <<'EOF'
glab mr create -b <base> -t "v$NEW_VERSION <type>: <summary>" -d "$(cat <<'EOF'
<MR body from above>
EOF
)"
+36 -8
View File
@@ -2727,8 +2727,8 @@ fi
Read the `STATE:` line and dispatch:
- **FRESH** → proceed with the bump action below (steps 14).
- **ALREADY_BUMPED** → skip the bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. Continue to the next step.
- **DRIFT_STALE_PKG** → a prior `/ship` bumped `VERSION` but failed to update `package.json`. Run the sync-only repair block below (after step 4). Do NOT re-bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body.
- **ALREADY_BUMPED** → skip the bump by default, BUT check for queue drift first: call `bin/gstack-next-version` with the implied bump level (derived from `CURRENT_VERSION` vs `BASE_VERSION`), compare its `.version` against `CURRENT_VERSION`. If they differ (queue moved since last ship), use **AskUserQuestion**: "VERSION drift detected: you claim v<CURRENT> but next available is v<NEW> (queue moved). A) Rebump to v<NEW> and rewrite CHANGELOG header + PR title (recommended), B) Keep v<CURRENT> — will be rejected by CI version-gate until resolved." If A, treat this as FRESH with `NEW_VERSION=<new>` and run steps 1-4 (which will also trigger Step 13 CHANGELOG header rewrite and Step 19 PR title rewrite). If B, reuse `CURRENT_VERSION` and warn that CI will likely reject. If util is offline, warn and reuse `CURRENT_VERSION`.
- **DRIFT_STALE_PKG** → a prior `/ship` bumped `VERSION` but failed to update `package.json`. Run the sync-only repair block below (after step 4). Do NOT re-bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. (Queue check still runs in ALREADY_BUMPED terms after repair.)
- **DRIFT_UNEXPECTED** → `/ship` has halted (exit 1). Resolve manually; /ship cannot tell which file is authoritative.
1. Read the current `VERSION` file (4-digit format: `MAJOR.MINOR.PATCH.MICRO`)
@@ -2741,9 +2741,33 @@ Read the `STATE:` line and dispatch:
- **MINOR** (2nd digit): **ASK the user** if ANY feature signal is detected, OR 500+ lines changed, OR new modules/packages added
- **MAJOR** (1st digit): **ASK the user** — only for milestones or breaking changes
3. Compute the new version:
- Bumping a digit resets all digits to its right to 0
- Example: `0.19.1.0` + PATCH → `0.19.2.0`
Save the chosen level as `BUMP_LEVEL` (one of `major`, `minor`, `patch`, `micro`). This is the user-intended level. The next step decides *placement* — the level stays the same even if queue-aware allocation has to advance past a claimed slot.
3. **Queue-aware version pick (workspace-aware ship, v1.6.4.0+).** Call `bin/gstack-next-version` to see what's already claimed by open PRs + active sibling Conductor worktrees, then render the queue state to the user:
```bash
QUEUE_JSON=$(bun run bin/gstack-next-version \
--base <base> \
--bump "$BUMP_LEVEL" \
--current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
NEW_VERSION=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
CLAIMED_COUNT=$(echo "$QUEUE_JSON" | jq -r '.claimed | length')
ACTIVE_SIBLING_COUNT=$(echo "$QUEUE_JSON" | jq -r '.active_siblings | length')
OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false')
REASON=$(echo "$QUEUE_JSON" | jq -r '.reason // ""')
```
- If `OFFLINE=true` or the util fails (auth expired, no `gh`/`glab`, network): fall back to local `BUMP_LEVEL` arithmetic (bump `BASE_VERSION` at the chosen level). Print `⚠ workspace-aware ship offline — using local bump only`. Continue.
- If `CLAIMED_COUNT > 0`: render the queue table to the user so they can see landing order at a glance:
```
Queue on <base> (vBASE_VERSION):
#<pr> <branch> → v<version> [⚠ collision with #<other>]
Active sibling workspaces (WIP, not yet PR'd):
<path> → v<version> (committed Nh ago)
Your branch will claim: vNEW_VERSION (<reason>)
```
- If `ACTIVE_SIBLING_COUNT > 0` and any active sibling's VERSION is `>= NEW_VERSION`, use **AskUserQuestion**: "Sibling workspace <path> has v<X> committed <N>h ago but hasn't PR'd yet. Wait for them to ship first, or advance past? A) Advance past (recommended for unrelated work), B) Abort /ship and sync up with sibling first."
- Validate `NEW_VERSION` matches `MAJOR.MINOR.PATCH.MICRO`. If util returns an empty or malformed version, fall back to local bump.
4. **Validate** `NEW_VERSION` and write it to **both** `VERSION` and `package.json`. This block runs only when `STATE: FRESH`.
@@ -3084,7 +3108,11 @@ gh pr view --json url,number,state -q 'if .state == "OPEN" then "PR #\(.number):
glab mr view -F json 2>/dev/null | jq -r 'if .state == "opened" then "MR_EXISTS" else "NO_MR" end' 2>/dev/null || echo "NO_MR"
```
If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body "..."` (GitHub) or `glab mr update -d "..."` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run. Print the existing URL and continue to Step 20.
If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body "..."` (GitHub) or `glab mr update -d "..."` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run.
**Also update the PR title** if the version changed on rerun. PR titles use the workspace-aware format `v<NEW_VERSION> <type>: <summary>` — version ALWAYS first. If the current title's version prefix doesn't match `NEW_VERSION`, run `gh pr edit --title "v$NEW_VERSION <type>: <summary>"` (or the `glab mr update -t ...` equivalent). This keeps the title truthful when Step 12's queue-drift detection rebumps a stale version. If the title has no `v<X.Y.Z.W>` prefix (a custom title kept intentionally), leave the title alone — only rewrite titles that already follow the format.
Print the existing URL and continue to Step 20.
If no PR/MR exists: create a pull request (GitHub) or merge request (GitLab) using the platform detected in Step 0.
@@ -3152,7 +3180,7 @@ you missed it.>
**If GitHub:**
```bash
gh pr create --base <base> --title "<type>: <summary>" --body "$(cat <<'EOF'
gh pr create --base <base> --title "v$NEW_VERSION <type>: <summary>" --body "$(cat <<'EOF'
<PR body from above>
EOF
)"
@@ -3161,7 +3189,7 @@ EOF
**If GitLab:**
```bash
glab mr create -b <base> -t "<type>: <summary>" -d "$(cat <<'EOF'
glab mr create -b <base> -t "v$NEW_VERSION <type>: <summary>" -d "$(cat <<'EOF'
<MR body from above>
EOF
)"
+182
View File
@@ -0,0 +1,182 @@
// Pure-function tests for bin/gstack-next-version.
// Covers the version arithmetic and slot-picking logic. Subprocess paths
// (gh/glab/git) are covered by the integration test at the bottom (skipped
// when the relevant CLI isn't available).
import { test, expect, describe } from "bun:test";
import {
parseVersion,
fmtVersion,
bumpVersion,
cmpVersion,
pickNextSlot,
markActiveSiblings,
} from "../bin/gstack-next-version";
describe("parseVersion", () => {
test("accepts 4-digit semver", () => {
expect(parseVersion("1.6.3.0")).toEqual([1, 6, 3, 0]);
expect(parseVersion("0.0.0.0")).toEqual([0, 0, 0, 0]);
expect(parseVersion("99.99.99.99")).toEqual([99, 99, 99, 99]);
});
test("trims whitespace", () => {
expect(parseVersion(" 1.2.3.4 \n")).toEqual([1, 2, 3, 4]);
});
test("rejects malformed", () => {
expect(parseVersion("1.2.3")).toBeNull();
expect(parseVersion("1.2.3.4.5")).toBeNull();
expect(parseVersion("v1.2.3.4")).toBeNull();
expect(parseVersion("")).toBeNull();
expect(parseVersion("not-a-version")).toBeNull();
expect(parseVersion("1.2.3.x")).toBeNull();
});
});
describe("bumpVersion", () => {
test("major zeros everything right", () => {
expect(bumpVersion([1, 6, 3, 0], "major")).toEqual([2, 0, 0, 0]);
expect(bumpVersion([1, 6, 3, 7], "major")).toEqual([2, 0, 0, 0]);
});
test("minor zeros patch+micro", () => {
expect(bumpVersion([1, 6, 3, 0], "minor")).toEqual([1, 7, 0, 0]);
expect(bumpVersion([1, 6, 3, 7], "minor")).toEqual([1, 7, 0, 0]);
});
test("patch zeros micro", () => {
expect(bumpVersion([1, 6, 3, 0], "patch")).toEqual([1, 6, 4, 0]);
expect(bumpVersion([1, 6, 3, 7], "patch")).toEqual([1, 6, 4, 0]);
});
test("micro increments slot 4", () => {
expect(bumpVersion([1, 6, 3, 0], "micro")).toEqual([1, 6, 3, 1]);
expect(bumpVersion([1, 6, 3, 7], "micro")).toEqual([1, 6, 3, 8]);
});
});
describe("cmpVersion", () => {
test("detects order", () => {
expect(cmpVersion([1, 6, 3, 0], [1, 6, 3, 0])).toBe(0);
expect(cmpVersion([1, 6, 4, 0], [1, 6, 3, 0])).toBeGreaterThan(0);
expect(cmpVersion([1, 6, 3, 0], [1, 6, 4, 0])).toBeLessThan(0);
expect(cmpVersion([2, 0, 0, 0], [1, 99, 99, 99])).toBeGreaterThan(0);
});
});
describe("pickNextSlot (the heart of queue-aware allocation)", () => {
const base: [number, number, number, number] = [1, 6, 3, 0];
test("happy path — no claims, clean bump", () => {
const r = pickNextSlot(base, [], "minor");
expect(fmtVersion(r.version)).toBe("1.7.0.0");
expect(r.reason).toMatch(/no collision/);
});
test("collision — one PR claims the next slot, bump past", () => {
const r = pickNextSlot(base, [[1, 7, 0, 0]], "minor");
expect(fmtVersion(r.version)).toBe("1.8.0.0");
expect(r.reason).toMatch(/bumped past/);
});
test("multi-collision — two PRs claim sequential slots", () => {
const r = pickNextSlot(base, [[1, 7, 0, 0], [1, 8, 0, 0]], "minor");
expect(fmtVersion(r.version)).toBe("1.9.0.0");
});
test("collision cross-level — queued MINOR bumps past my PATCH", () => {
// Queue has 1.7.0.0 (minor), my bump is patch. I should land at 1.7.1.0
// (patch relative to the highest claim).
const r = pickNextSlot(base, [[1, 7, 0, 0]], "patch");
expect(fmtVersion(r.version)).toBe("1.7.1.0");
});
test("claims below base are ignored", () => {
const r = pickNextSlot(base, [[1, 5, 0, 0], [1, 6, 2, 0]], "patch");
expect(fmtVersion(r.version)).toBe("1.6.4.0");
expect(r.reason).toMatch(/no collision/);
});
test("claims equal to base are treated as no-claim", () => {
// The caller is expected to pre-filter base-equal claims out, but even if
// one slipped through, we don't want to inflate past it.
const r = pickNextSlot(base, [], "micro");
expect(fmtVersion(r.version)).toBe("1.6.3.1");
});
test("major collision — competing majors", () => {
const r = pickNextSlot(base, [[2, 0, 0, 0]], "major");
expect(fmtVersion(r.version)).toBe("3.0.0.0");
});
test("unsorted claims still resolve correctly", () => {
const r = pickNextSlot(base, [[1, 9, 0, 0], [1, 7, 0, 0], [1, 8, 0, 0]], "minor");
expect(fmtVersion(r.version)).toBe("1.10.0.0");
});
});
describe("markActiveSiblings", () => {
const base: [number, number, number, number] = [1, 6, 3, 0];
const now = Math.floor(Date.now() / 1000);
test("flags siblings that are ahead of base AND recent AND have no PR", () => {
const siblings = [
{ path: "/a", branch: "feat/alpha", version: "1.7.0.0", last_commit_ts: now - 60, has_open_pr: false, is_active: false },
];
const r = markActiveSiblings(siblings, base);
expect(r[0].is_active).toBe(true);
});
test("does not flag siblings with open PRs (already in the queue)", () => {
const siblings = [
{ path: "/a", branch: "feat/alpha", version: "1.7.0.0", last_commit_ts: now - 60, has_open_pr: true, is_active: false },
];
expect(markActiveSiblings(siblings, base)[0].is_active).toBe(false);
});
test("does not flag stale siblings (commit > 24h old)", () => {
const siblings = [
{ path: "/a", branch: "feat/alpha", version: "1.7.0.0", last_commit_ts: now - 25 * 3600, has_open_pr: false, is_active: false },
];
expect(markActiveSiblings(siblings, base)[0].is_active).toBe(false);
});
test("does not flag siblings at or below base", () => {
const siblings = [
{ path: "/a", branch: "feat/alpha", version: "1.6.3.0", last_commit_ts: now - 60, has_open_pr: false, is_active: false },
{ path: "/b", branch: "feat/beta", version: "1.5.0.0", last_commit_ts: now - 60, has_open_pr: false, is_active: false },
];
const r = markActiveSiblings(siblings, base);
expect(r[0].is_active).toBe(false);
expect(r[1].is_active).toBe(false);
});
});
// 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)", () => {
test("CLI runs against real repo and emits parseable JSON", async () => {
const proc = Bun.spawnSync([
"bun",
"run",
"./bin/gstack-next-version",
"--base",
"main",
"--bump",
"patch",
"--current-version",
"1.6.3.0",
"--workspace-root",
"null", // skip sibling scan in CI
]);
const out = new TextDecoder().decode(proc.stdout);
const parsed = JSON.parse(out);
expect(parsed).toHaveProperty("version");
expect(parseVersion(parsed.version)).not.toBeNull();
expect(parsed).toHaveProperty("bump", "patch");
expect(parsed).toHaveProperty("host");
expect(["github", "gitlab", "unknown"]).toContain(parsed.host);
expect(parsed).toHaveProperty("claimed");
expect(Array.isArray(parsed.claimed)).toBe(true);
expect(parsed).toHaveProperty("siblings");
expect(parsed.siblings).toEqual([]); // --workspace-root null disabled scanning
});
});