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,64 @@
|
||||
name: PR Title Sync
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, edited]
|
||||
paths:
|
||||
- 'VERSION'
|
||||
|
||||
concurrency:
|
||||
group: pr-title-sync-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Sync PR title to VERSION
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
if: github.actor != 'github-actions[bot]'
|
||||
steps:
|
||||
- name: Checkout PR head
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Read VERSION + current title
|
||||
id: inspect
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||
TITLE=$(jq -r '.pull_request.title' "$GITHUB_EVENT_PATH")
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
# Only rewrite titles that ALREADY follow the v<X.Y.Z.W> prefix pattern.
|
||||
# Custom titles (no prefix) are left alone — user kept them intentionally.
|
||||
if printf '%s' "$TITLE" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+ '; then
|
||||
PREFIX=$(printf '%s' "$TITLE" | awk '{print $1}')
|
||||
REST=$(printf '%s' "$TITLE" | sed 's/^v[0-9][0-9.]* //')
|
||||
{
|
||||
echo "prefix=$PREFIX"
|
||||
echo "rest=$REST"
|
||||
echo "eligible=true"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "eligible=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Rewrite title if version changed
|
||||
if: steps.inspect.outputs.eligible == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUM: ${{ github.event.pull_request.number }}
|
||||
NEW_V: ${{ steps.inspect.outputs.version }}
|
||||
OLD_PREFIX: ${{ steps.inspect.outputs.prefix }}
|
||||
REST: ${{ steps.inspect.outputs.rest }}
|
||||
run: |
|
||||
if [ "v$NEW_V" = "$OLD_PREFIX" ]; then
|
||||
echo "Title already matches v$NEW_V; no change."
|
||||
exit 0
|
||||
fi
|
||||
NEW_TITLE="v$NEW_V $REST"
|
||||
echo "Rewriting: $OLD_PREFIX ... → v$NEW_V ..."
|
||||
gh pr edit "$PR_NUM" --title "$NEW_TITLE"
|
||||
@@ -0,0 +1,74 @@
|
||||
name: Version Gate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'VERSION'
|
||||
- 'CHANGELOG.md'
|
||||
- 'package.json'
|
||||
|
||||
concurrency:
|
||||
group: version-gate-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check VERSION is not stale vs queue
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Checkout PR head
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Read versions
|
||||
id: versions
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PR_VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||
BASE_REF="${{ github.event.pull_request.base.ref }}"
|
||||
git fetch origin "$BASE_REF" --depth=1 --quiet || true
|
||||
BASE_VERSION=$(git show "origin/$BASE_REF:VERSION" 2>/dev/null | tr -d '[:space:]' || echo "0.0.0.0")
|
||||
{
|
||||
echo "pr_version=$PR_VERSION"
|
||||
echo "base_version=$BASE_VERSION"
|
||||
echo "base_ref=$BASE_REF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Detect bump level
|
||||
id: bump
|
||||
run: |
|
||||
LEVEL=$(bun run scripts/detect-bump.ts "${{ steps.versions.outputs.base_version }}" "${{ steps.versions.outputs.pr_version }}")
|
||||
echo "level=$LEVEL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Query queue (util) — fail-open on error
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set +e
|
||||
bun run bin/gstack-next-version \
|
||||
--base "${{ steps.versions.outputs.base_ref }}" \
|
||||
--bump "${{ steps.bump.outputs.level }}" \
|
||||
--current-version "${{ steps.versions.outputs.base_version }}" \
|
||||
--workspace-root null \
|
||||
--exclude-pr "${{ github.event.pull_request.number }}" \
|
||||
> next.json 2> next.err
|
||||
RC=$?
|
||||
if [ "$RC" != "0" ] || [ ! -s next.json ]; then
|
||||
echo '{"offline":true}' > next.json
|
||||
echo "::warning::util exit=$RC — failing open. stderr:"
|
||||
cat next.err || true
|
||||
fi
|
||||
|
||||
- name: Compare PR VERSION to next free slot
|
||||
env:
|
||||
PR_VERSION: ${{ steps.versions.outputs.pr_version }}
|
||||
run: |
|
||||
bun run scripts/compare-pr-version.ts next.json "${{ github.event.pull_request.number }}"
|
||||
@@ -0,0 +1,72 @@
|
||||
# GitLab CI parity for workspace-aware ship.
|
||||
# Mirrors .github/workflows/version-gate.yml and pr-title-sync.yml.
|
||||
# Projects that mirror to GitLab get the same protection as GitHub.
|
||||
|
||||
stages:
|
||||
- check
|
||||
|
||||
variables:
|
||||
BUN_VERSION: "1.3.10"
|
||||
|
||||
.setup-bun: &setup-bun
|
||||
- apt-get update -qq && apt-get install -qq -y curl jq git
|
||||
- curl -fsSL https://bun.sh/install | bash -s "bun-v$BUN_VERSION"
|
||||
- export PATH="$HOME/.bun/bin:$PATH"
|
||||
|
||||
version-gate:
|
||||
stage: check
|
||||
image: debian:stable-slim
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
changes:
|
||||
- VERSION
|
||||
- CHANGELOG.md
|
||||
- package.json
|
||||
script:
|
||||
- *setup-bun
|
||||
- PR_VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||
- BASE_VERSION=$(git show "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME:VERSION" 2>/dev/null | tr -d '[:space:]' || echo "0.0.0.0")
|
||||
- LEVEL=$(bun run scripts/detect-bump.ts "$BASE_VERSION" "$PR_VERSION")
|
||||
# Util fail-open: on non-zero exit, emit offline marker
|
||||
- |
|
||||
set +e
|
||||
bun run bin/gstack-next-version \
|
||||
--base "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" \
|
||||
--bump "$LEVEL" \
|
||||
--current-version "$BASE_VERSION" \
|
||||
--workspace-root null \
|
||||
--exclude-pr "$CI_MERGE_REQUEST_IID" \
|
||||
> next.json
|
||||
RC=$?
|
||||
if [ "$RC" != "0" ] || [ ! -s next.json ]; then
|
||||
echo '{"offline":true}' > next.json
|
||||
echo "WARNING: util exit=$RC — failing open"
|
||||
fi
|
||||
set -e
|
||||
- PR_VERSION="$PR_VERSION" bun run scripts/compare-pr-version.ts next.json "$CI_MERGE_REQUEST_IID"
|
||||
|
||||
pr-title-sync:
|
||||
stage: check
|
||||
image: debian:stable-slim
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
changes:
|
||||
- VERSION
|
||||
script:
|
||||
- apt-get update -qq && apt-get install -qq -y curl jq git
|
||||
- curl -fsSL https://gitlab.com/gitlab-org/cli/-/releases/permalink/latest/downloads/glab_linux_amd64.deb -o glab.deb && dpkg -i glab.deb
|
||||
- VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||
- TITLE="$CI_MERGE_REQUEST_TITLE"
|
||||
- |
|
||||
if printf '%s' "$TITLE" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+ '; then
|
||||
PREFIX=$(printf '%s' "$TITLE" | awk '{print $1}')
|
||||
REST=$(printf '%s' "$TITLE" | sed 's/^v[0-9][0-9.]* //')
|
||||
if [ "v$VERSION" != "$PREFIX" ]; then
|
||||
echo "Rewriting: $PREFIX ... → v$VERSION ..."
|
||||
glab mr update "$CI_MERGE_REQUEST_IID" -t "v$VERSION $REST"
|
||||
else
|
||||
echo "Title already matches v$VERSION; no change."
|
||||
fi
|
||||
else
|
||||
echo "Title does not use v<X.Y.Z.W> prefix — leaving alone."
|
||||
fi
|
||||
@@ -1,5 +1,56 @@
|
||||
# Changelog
|
||||
|
||||
## [1.11.0.0] - 2026-04-23
|
||||
|
||||
## **Workspace-aware ship. Two open PRs can't both claim the same VERSION anymore.**
|
||||
|
||||
If you run gstack in multiple Conductor windows at once, you've probably seen this: two branches bump to the same version, whoever merges second silently overwrites the first one's CHANGELOG entry or lands with a duplicate header, and nobody notices until a `grep "^## \["` later. This release makes that collision impossible by construction. `/ship` now queries the open PR queue, sees what versions are already claimed, and picks the next free slot at your chosen bump level. If a collision is detected between ship and land, the land step aborts and tells you to rerun `/ship` rather than silently overwriting. A new `/landing-report` command shows the whole queue on demand.
|
||||
|
||||
### What changes for you
|
||||
|
||||
Run `/ship` in one Conductor window while another has an open PR claiming v1.7.0.0. Your ship now sees the claim, renders a queue table, and picks the next free slot above it (same bump level). The PR title starts with `v<X.Y.Z.W>` so landing order is visible in `gh pr list` without opening each PR. If a sibling workspace has uncommitted work at a higher VERSION and looks active (commit in the last 24h), `/ship` asks whether to wait for them or advance past. If the queue shifts between ship and merge, CI's new version-gate catches it, and rerunning `/ship` rewrites VERSION, package.json, CHANGELOG, and the PR title atomically. This very release dogfooded the drift path: the original ship at v1.8.0.0 went stale when three other PRs landed first, and the merge-back-to-main rebump (v1.8.0.0 → v1.11.0.0) happened via the same queue-aware codepath it introduces.
|
||||
|
||||
### What shipped (by the numbers)
|
||||
|
||||
- `bin/gstack-next-version` — ~390-line Bun/TS util. 21 passing fixture tests covering happy path, 8 collision scenarios, offline fallback, fork-PR filtering, sibling activity detection, self-PR auto-exclusion.
|
||||
- Host parity: GitHub + GitLab both supported. CI gates: `.github/workflows/version-gate.yml`, `.github/workflows/pr-title-sync.yml`, plus `.gitlab-ci.yml` mirror.
|
||||
- Fail-open semantics on util errors (network, auth, bug). A gstack bug never freezes your merge queue. Fail-closed on confirmed collisions.
|
||||
- `/landing-report` skill — read-only dashboard showing queue, siblings, and what all four bump levels would claim.
|
||||
- `workspace_root` config key, default `$HOME/conductor/workspaces`, null disables sibling scan for non-Conductor users.
|
||||
|
||||
### What this means for teams running parallel workspaces
|
||||
|
||||
If you're routinely running 3-10 Conductor windows against the same repo, this is the capability that lets the model scale. Before: you mostly got away with it because you noticed collisions by eye. After: the queue is an observable surface, and the system refuses to ship a stale version. `/landing-report` is the new "where am I in line" check when you're about to open PR #6 for the day. Run it before `/ship` if you want to see what's coming without shipping.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
#### Added
|
||||
|
||||
- `bin/gstack-next-version`. Host-aware (GitHub + GitLab + unknown) VERSION allocator. Queries open PRs, fetches each PR's VERSION at head (bounded concurrency, 10 parallel), scans sibling Conductor worktrees, picks the next free slot. Pure reader, never writes files. Supports `--exclude-pr <N>` to filter out the PR being checked (prevents self-reference when CI runs against the PR's own VERSION).
|
||||
- `scripts/detect-bump.ts`, `scripts/compare-pr-version.ts`. CI gate helpers. Three exit paths: pass, block on confirmed collision, fail-open on util errors.
|
||||
- `.github/workflows/version-gate.yml`. Merge-time collision gate. Runs when VERSION/CHANGELOG/package.json changes on a PR.
|
||||
- `.github/workflows/pr-title-sync.yml`. Auto-rewrites PR title when VERSION changes on push, only for titles already carrying the `v<X.Y.Z.W>` prefix (custom titles left alone, idempotent).
|
||||
- `.gitlab-ci.yml`. GitLab CI parity. Both jobs mirrored with the same fail-open semantics.
|
||||
- `landing-report/SKILL.md.tmpl`. New `/landing-report` or `/gstack-landing-report` skill. Read-only dashboard.
|
||||
- `bin/gstack-config`. New `workspace_root` key. Default `$HOME/conductor/workspaces`, `null` disables sibling scan.
|
||||
|
||||
#### Changed
|
||||
|
||||
- `ship/SKILL.md.tmpl` Step 12. Queue-aware VERSION pick in FRESH path, drift detection in ALREADY_BUMPED path. On detected drift the user is prompted to rebump, which runs the full metadata path (VERSION + package.json + CHANGELOG header + PR title) atomically so nothing goes stale.
|
||||
- `ship/SKILL.md.tmpl` Step 19. PR title format is now `v<X.Y.Z.W> <type>: <summary>`, version ALWAYS first. Rerun path updates the title (not just the body) when VERSION changed. Both GitHub and GitLab paths.
|
||||
- `land-and-deploy/SKILL.md.tmpl`. New Step 3.4 pre-merge drift detection. Aborts with a clear rerun-/ship instruction rather than auto-mutating files. Rerunning `/ship` is the clean path because ship owns the full metadata flow.
|
||||
- `review/SKILL.md.tmpl`. New Step 3.4 advisory one-liner showing queue status. Non-blocking.
|
||||
- `CLAUDE.md`. Versioning invariant paragraph. Documents that VERSION is a monotonic sequence, not a strict semver commitment, and queue-advance within a bump level is permitted.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Self-reference bug in the version gate. The first live CI run (PR #1168 at v1.8.0.0) was rejected as "stale" because the util counted the PR being checked as a queued claim, inflating the next slot by one. Fixed with `--exclude-pr` flag + `gh pr view` auto-detect so the util silently filters the current branch's PR. Caught and fixed in the same ship — exactly the dogfood loop the release is designed for.
|
||||
|
||||
#### For contributors
|
||||
|
||||
- `test/gstack-next-version.test.ts`. 21 pure-function tests (parseVersion / bumpVersion / cmpVersion / pickNextSlot with 8 collision scenarios / markActiveSiblings 4 cases) plus a CLI smoke test against the live repo.
|
||||
- Golden ship fixtures refreshed for all three hosts (claude, codex, factory) after Step 12 and Step 19 template changes. This is exactly the blast radius Codex flagged during the CEO review (cross-model tension #8), handled in the same PR rather than as a follow-up.
|
||||
|
||||
## [1.10.1.0] - 2026-04-23
|
||||
|
||||
## **We tried to make Opus 4.7 faster with a prompt. Measurement said it got slower. Pulled the bullet.**
|
||||
|
||||
@@ -407,6 +407,16 @@ No auto-merging. No "I'll just clean this up."
|
||||
|
||||
## CHANGELOG + VERSION style
|
||||
|
||||
**Versioning invariant (workspace-aware ship).** VERSION is a monotonic ordered
|
||||
release identifier, not a strict semver commitment. The bump level
|
||||
(major/minor/patch/micro) expresses intent at ship time. Queue-advancing past a
|
||||
claimed version within the same bump level is explicitly permitted — if branch A
|
||||
claims v1.7.0.0 as a MINOR and branch B is also a MINOR, B lands at v1.8.0.0
|
||||
(still a MINOR relative to main). Downstream consumers must NOT rely on
|
||||
"MINOR = feature-only, PATCH = fix-only" as a strict contract. This is why
|
||||
`bin/gstack-next-version` advances within the chosen bump level rather than
|
||||
repicking the level when collisions happen.
|
||||
|
||||
**VERSION and CHANGELOG are branch-scoped.** Every feature branch that ships gets its
|
||||
own version bump and CHANGELOG entry. The entry describes what THIS branch adds —
|
||||
not what was already on main.
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# TODOS
|
||||
|
||||
## Testing
|
||||
|
||||
### `security-bench-haiku-responses.json` is 27MB, violates the 2MB tracked-file gate
|
||||
|
||||
**What:** `browse/test/fixtures/security-bench-haiku-responses.json` landed on main at v1.6.4.0 (PR #1135) at 27MB. The `no compiled binaries in git > git tracks no files larger than 2MB` gate in `test/skill-validation.test.ts:1623` fails on main and on every feature branch that merges main afterward.
|
||||
|
||||
**Why:** The fixture is a legitimate CI replay corpus (real Haiku responses from the 500-case BrowseSafe-Bench) used to verify the ensemble classifier deterministically. But 13x over the 2MB limit means it will keep failing the validation test for every future ship.
|
||||
|
||||
**Pros:** Removes a pre-existing failure that wastes a triage slot in every /ship run.
|
||||
|
||||
**Cons:** Moving to git-lfs adds a dependency. Splitting into chunks risks breaking the bench test. External hosting adds a CI fetch step.
|
||||
|
||||
**Context:** Noticed during workspace-aware-ship /ship on 2026-04-23 when the post-merge test suite flagged this single failure. Introduced on main in PR #1135 (`v1.6.4.0: cut Haiku classifier FP from 44% to 23%`), commit d75402bb. Two reasonable paths: (a) split into multiple ≤2MB chunks and load them in the bench test, (b) move to git-lfs.
|
||||
|
||||
**Effort:** M (human: ~2-3h / CC: ~20 min)
|
||||
**Priority:** P1 (not blocking ship, but every future /ship triages the same failure)
|
||||
**Depends on:** nothing
|
||||
|
||||
---
|
||||
|
||||
## Context skills
|
||||
|
||||
### `/context-save --lane` + `/context-restore --lane` for parallel workstreams
|
||||
|
||||
+12
-4
@@ -78,6 +78,13 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne
|
||||
# gstack_contributor: false # true = file field reports when gstack misbehaves
|
||||
# skip_eng_review: false # true = skip eng review gate in /ship (not recommended)
|
||||
#
|
||||
# ─── Workspace-aware ship ────────────────────────────────────────────
|
||||
# workspace_root: $HOME/conductor/workspaces # Where /ship looks for sibling
|
||||
# # Conductor worktrees when picking a VERSION slot.
|
||||
# # Set to "null" to disable sibling scanning entirely.
|
||||
# # Non-Conductor users can point this at any directory
|
||||
# # that holds parallel worktrees of the same repo.
|
||||
#
|
||||
'
|
||||
|
||||
# DEFAULTS table — canonical default values for known keys.
|
||||
@@ -96,6 +103,7 @@ lookup_default() {
|
||||
codex_reviews) echo "enabled" ;;
|
||||
gstack_contributor) echo "false" ;;
|
||||
skip_eng_review) echo "false" ;;
|
||||
workspace_root) echo "$HOME/conductor/workspaces" ;;
|
||||
cross_project_learnings) echo "" ;; # intentionally empty → unset triggers first-time prompt
|
||||
gbrain_sync_mode) echo "off" ;;
|
||||
gbrain_sync_mode_prompted) echo "false" ;;
|
||||
@@ -162,8 +170,8 @@ case "${1:-}" in
|
||||
echo "# ─── Active values (including defaults for unset keys) ───"
|
||||
for KEY in proactive routing_declined telemetry auto_upgrade update_check \
|
||||
skill_prefix checkpoint_mode checkpoint_push codex_reviews \
|
||||
gstack_contributor skip_eng_review gbrain_sync_mode \
|
||||
gbrain_sync_mode_prompted; do
|
||||
gstack_contributor skip_eng_review workspace_root \
|
||||
gbrain_sync_mode gbrain_sync_mode_prompted; do
|
||||
VALUE=$(grep -E "^${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true)
|
||||
SOURCE="default"
|
||||
if [ -n "$VALUE" ]; then
|
||||
@@ -178,8 +186,8 @@ case "${1:-}" in
|
||||
echo "# gstack-config defaults"
|
||||
for KEY in proactive routing_declined telemetry auto_upgrade update_check \
|
||||
skill_prefix checkpoint_mode checkpoint_push codex_reviews \
|
||||
gstack_contributor skip_eng_review gbrain_sync_mode \
|
||||
gbrain_sync_mode_prompted; do
|
||||
gstack_contributor skip_eng_review workspace_root \
|
||||
gbrain_sync_mode gbrain_sync_mode_prompted; do
|
||||
printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")"
|
||||
done
|
||||
;;
|
||||
|
||||
Executable
+477
@@ -0,0 +1,477 @@
|
||||
#!/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.
|
||||
//
|
||||
// Usage:
|
||||
// gstack-next-version --base <branch> --bump <major|minor|patch|micro> \
|
||||
// --current-version <X.Y.Z.W> [--workspace-root <path>|null] [--json]
|
||||
//
|
||||
// Exit codes:
|
||||
// 0 — emitted JSON successfully (may include "offline":true or "host":"unknown")
|
||||
// 2 — invalid arguments
|
||||
// 3 — util bug (unexpected exception)
|
||||
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
type Bump = "major" | "minor" | "patch" | "micro";
|
||||
type Version = [number, number, number, number];
|
||||
|
||||
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;
|
||||
bump: Bump;
|
||||
host: "github" | "gitlab" | "unknown";
|
||||
offline: boolean;
|
||||
claimed: ClaimedPR[];
|
||||
siblings: Sibling[];
|
||||
active_siblings: Sibling[];
|
||||
reason: string;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
const ACTIVE_SIBLING_MAX_AGE_S = 24 * 60 * 60;
|
||||
const GH_API_CONCURRENCY = 10;
|
||||
|
||||
function parseVersion(s: string): Version | null {
|
||||
const m = s.trim().match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (!m) return null;
|
||||
return [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
|
||||
}
|
||||
|
||||
function fmtVersion(v: Version): string {
|
||||
return v.join(".");
|
||||
}
|
||||
|
||||
function bumpVersion(v: Version, level: Bump): Version {
|
||||
switch (level) {
|
||||
case "major":
|
||||
return [v[0] + 1, 0, 0, 0];
|
||||
case "minor":
|
||||
return [v[0], v[1] + 1, 0, 0];
|
||||
case "patch":
|
||||
return [v[0], v[1], v[2] + 1, 0];
|
||||
case "micro":
|
||||
return [v[0], v[1], v[2], v[3] + 1];
|
||||
}
|
||||
}
|
||||
|
||||
function cmpVersion(a: Version, b: Version): number {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (a[i] !== b[i]) return a[i] - b[i];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 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.
|
||||
function pickNextSlot(base: Version, claimed: Version[], level: Bump): { version: Version; reason: string } {
|
||||
let candidate = bumpVersion(base, level);
|
||||
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);
|
||||
if (cmpVersion(bumpedPastHighest, candidate) > 0) {
|
||||
return { version: bumpedPastHighest, reason: `bumped past claimed ${fmtVersion(highest)}` };
|
||||
}
|
||||
}
|
||||
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) : ""),
|
||||
};
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
function readBaseVersion(base: 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", "origin", base, "--quiet"], 10000);
|
||||
const r = runCommand("git", ["show", `origin/${base}:VERSION`]);
|
||||
if (!r.ok) {
|
||||
warnings.push(`could not read VERSION 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 }> {
|
||||
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 };
|
||||
}
|
||||
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 };
|
||||
}
|
||||
// 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).
|
||||
const results: ClaimedPR[] = [];
|
||||
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;
|
||||
// gh passes branch name via argv, not shell — safe.
|
||||
const content = runCommand("gh", [
|
||||
"api",
|
||||
`repos/{owner}/{repo}/contents/VERSION?ref=${encodeURIComponent(pr.headRefName)}`,
|
||||
"-q",
|
||||
".content",
|
||||
]);
|
||||
if (!content.ok) {
|
||||
warnings.push(`PR #${pr.number}: could not fetch VERSION (fork or private)`);
|
||||
continue;
|
||||
}
|
||||
let versionStr: string;
|
||||
try {
|
||||
versionStr = Buffer.from(content.stdout.trim(), "base64").toString("utf8").trim();
|
||||
} catch {
|
||||
warnings.push(`PR #${pr.number}: VERSION is not valid base64`);
|
||||
continue;
|
||||
}
|
||||
if (!parseVersion(versionStr)) {
|
||||
warnings.push(`PR #${pr.number}: VERSION is malformed (${versionStr})`);
|
||||
continue;
|
||||
}
|
||||
results.push({ pr: pr.number, branch: pr.headRefName, version: versionStr, url: pr.url });
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return { claimed: results, offline: false };
|
||||
}
|
||||
|
||||
async function fetchGitlabClaimed(base: 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) {
|
||||
const content = runCommand("glab", [
|
||||
"api",
|
||||
`projects/:id/repository/files/VERSION?ref=${encodeURIComponent(mr.source_branch)}`,
|
||||
]);
|
||||
if (!content.ok) {
|
||||
warnings.push(`MR !${mr.iid}: could not fetch VERSION`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const j = JSON.parse(content.stdout);
|
||||
const versionStr = Buffer.from(j.content, "base64").toString("utf8").trim();
|
||||
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, 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, "VERSION");
|
||||
if (!existsSync(versionFile)) continue;
|
||||
let version: string;
|
||||
try {
|
||||
version = readFileSync(versionFile, "utf8").trim();
|
||||
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; help: boolean } {
|
||||
let base = "";
|
||||
let bump: Bump | "" = "";
|
||||
let current = "";
|
||||
let workspaceRoot: string | undefined;
|
||||
let excludePR: number | null = null;
|
||||
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 === "--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) 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, 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;
|
||||
}
|
||||
|
||||
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>]",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
const warnings: string[] = [];
|
||||
const host = detectHost();
|
||||
const baseVersion = args.current || readBaseVersion(args.base, warnings);
|
||||
const baseParsed = parseVersion(baseVersion);
|
||||
if (!baseParsed) {
|
||||
console.error(`Error: could not parse base version '${baseVersion}'`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
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;
|
||||
if (host === "github") {
|
||||
({ claimed, offline } = await fetchGithubClaimed(args.base, excludePR, warnings));
|
||||
} else if (host === "gitlab") {
|
||||
({ claimed, offline } = await fetchGitlabClaimed(args.base, excludePR, warnings));
|
||||
} else {
|
||||
warnings.push("host unknown; queue-awareness unavailable");
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
const workspaceRoot = resolveWorkspaceRoot(args.workspaceRoot);
|
||||
const siblings = markActiveSiblings(scanSiblings(workspaceRoot, 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);
|
||||
finalReason = `bumped past active sibling ${fmtVersion(highest)}`;
|
||||
}
|
||||
|
||||
const out: Output = {
|
||||
version: fmtVersion(finalVersion),
|
||||
current_version: args.current || baseVersion,
|
||||
base_version: baseVersion,
|
||||
bump: args.bump,
|
||||
host,
|
||||
offline,
|
||||
claimed: realClaims,
|
||||
siblings,
|
||||
active_siblings: activeSiblings,
|
||||
reason: finalReason,
|
||||
warnings,
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out, null, 2) + "\n");
|
||||
}
|
||||
|
||||
// Pure-function exports for testing
|
||||
export { parseVersion, fmtVersion, bumpVersion, cmpVersion, pickNextSlot, markActiveSiblings };
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
@@ -1447,6 +1447,49 @@ If timeout (15 min): **STOP.** "CI has been running for over 15 minutes — that
|
||||
|
||||
---
|
||||
|
||||
## Step 3.4: VERSION drift detection (workspace-aware ship)
|
||||
|
||||
Before gathering readiness evidence, verify that the VERSION this PR claims is still the next free slot. A sibling workspace may have shipped and landed since `/ship` ran, leaving this PR's VERSION stale.
|
||||
|
||||
```bash
|
||||
BRANCH_VERSION=$(git show HEAD:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "")
|
||||
BASE_BRANCH=$(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || echo main)
|
||||
BASE_VERSION=$(git show origin/$BASE_BRANCH:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "")
|
||||
|
||||
# Imply bump level by comparing branch VERSION to base (crude but good enough for drift detection)
|
||||
# We don't need the exact original level — we just need "a level" that passes to the util.
|
||||
# If the minor digit advanced, call it minor; patch digit, patch; etc. If base > branch, skip (not ours to land).
|
||||
# For simplicity: use "patch" as a conservative default; util handles collision-past regardless of input level.
|
||||
QUEUE_JSON=$(bun run bin/gstack-next-version \
|
||||
--base "$BASE_BRANCH" \
|
||||
--bump patch \
|
||||
--current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
|
||||
NEXT_SLOT=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
|
||||
OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false')
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
1. If `OFFLINE=true` or the util fails: print `⚠ VERSION drift check unavailable (util offline) — proceeding with PR version v<BRANCH_VERSION>`. Continue to Step 3.5. CI's version-gate job is the backstop.
|
||||
|
||||
2. If `BRANCH_VERSION` is already `>=` than `NEXT_SLOT`: no drift (or our PR is ahead of the queue). Continue.
|
||||
|
||||
3. If drift is detected (a PR landed ahead of us and `BRANCH_VERSION < NEXT_SLOT`): **STOP** and print exactly:
|
||||
```
|
||||
⚠ VERSION drift detected.
|
||||
This PR claims: v<BRANCH_VERSION>
|
||||
Next free slot: v<NEXT_SLOT> (queue moved since last /ship)
|
||||
|
||||
Rerun /ship from the feature branch to reconcile. /ship's ALREADY_BUMPED
|
||||
branch will detect the drift and rewrite VERSION + CHANGELOG header + PR title
|
||||
atomically. Do NOT merge from here — the landed PR would overwrite the other
|
||||
branch's CHANGELOG entry or land with a duplicate version header.
|
||||
```
|
||||
|
||||
Exit non-zero. Do NOT auto-bump from `/land-and-deploy` — rerunning `/ship` is the clean path (it already handles VERSION + package.json + CHANGELOG header + PR title atomically via Step 12 ALREADY_BUMPED detection).
|
||||
|
||||
---
|
||||
|
||||
## Step 3.5: Pre-merge readiness gate
|
||||
|
||||
**This is the critical safety check before an irreversible merge.** The merge cannot
|
||||
|
||||
@@ -328,6 +328,49 @@ If timeout (15 min): **STOP.** "CI has been running for over 15 minutes — that
|
||||
|
||||
---
|
||||
|
||||
## Step 3.4: VERSION drift detection (workspace-aware ship)
|
||||
|
||||
Before gathering readiness evidence, verify that the VERSION this PR claims is still the next free slot. A sibling workspace may have shipped and landed since `/ship` ran, leaving this PR's VERSION stale.
|
||||
|
||||
```bash
|
||||
BRANCH_VERSION=$(git show HEAD:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "")
|
||||
BASE_BRANCH=$(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || echo main)
|
||||
BASE_VERSION=$(git show origin/$BASE_BRANCH:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "")
|
||||
|
||||
# Imply bump level by comparing branch VERSION to base (crude but good enough for drift detection)
|
||||
# We don't need the exact original level — we just need "a level" that passes to the util.
|
||||
# If the minor digit advanced, call it minor; patch digit, patch; etc. If base > branch, skip (not ours to land).
|
||||
# For simplicity: use "patch" as a conservative default; util handles collision-past regardless of input level.
|
||||
QUEUE_JSON=$(bun run bin/gstack-next-version \
|
||||
--base "$BASE_BRANCH" \
|
||||
--bump patch \
|
||||
--current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
|
||||
NEXT_SLOT=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
|
||||
OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false')
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
1. If `OFFLINE=true` or the util fails: print `⚠ VERSION drift check unavailable (util offline) — proceeding with PR version v<BRANCH_VERSION>`. Continue to Step 3.5. CI's version-gate job is the backstop.
|
||||
|
||||
2. If `BRANCH_VERSION` is already `>=` than `NEXT_SLOT`: no drift (or our PR is ahead of the queue). Continue.
|
||||
|
||||
3. If drift is detected (a PR landed ahead of us and `BRANCH_VERSION < NEXT_SLOT`): **STOP** and print exactly:
|
||||
```
|
||||
⚠ VERSION drift detected.
|
||||
This PR claims: v<BRANCH_VERSION>
|
||||
Next free slot: v<NEXT_SLOT> (queue moved since last /ship)
|
||||
|
||||
Rerun /ship from the feature branch to reconcile. /ship's ALREADY_BUMPED
|
||||
branch will detect the drift and rewrite VERSION + CHANGELOG header + PR title
|
||||
atomically. Do NOT merge from here — the landed PR would overwrite the other
|
||||
branch's CHANGELOG entry or land with a duplicate version header.
|
||||
```
|
||||
|
||||
Exit non-zero. Do NOT auto-bump from `/land-and-deploy` — rerunning `/ship` is the clean path (it already handles VERSION + package.json + CHANGELOG header + PR title atomically via Step 12 ALREADY_BUMPED detection).
|
||||
|
||||
---
|
||||
|
||||
## Step 3.5: Pre-merge readiness gate
|
||||
|
||||
**This is the critical safety check before an irreversible merge.** The merge cannot
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,163 @@
|
||||
---
|
||||
name: landing-report
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Read-only queue dashboard for workspace-aware ship. Shows which VERSION slots
|
||||
are currently claimed by open PRs, which sibling Conductor workspaces have
|
||||
WIP work likely to ship soon, and what slot /ship would pick next. No
|
||||
mutations — just a snapshot. Use when asked to "landing report", "what's in
|
||||
the queue", "show me open PRs", or "which version do I claim next". (gstack)
|
||||
triggers:
|
||||
- landing report
|
||||
- version queue
|
||||
- ship queue
|
||||
- what version comes next
|
||||
- show open PR versions
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
sensitive: false
|
||||
---
|
||||
|
||||
# /landing-report — Version Queue Dashboard
|
||||
|
||||
{{PREAMBLE}}
|
||||
|
||||
---
|
||||
|
||||
## Why this skill exists
|
||||
|
||||
When you're running 5-10 parallel Conductor workspaces, it helps to see — at a
|
||||
glance — which version numbers are claimed, by whom, and what slot your next
|
||||
`/ship` would land in. This skill is a read-only call into the same
|
||||
`bin/gstack-next-version` utility `/ship` uses, but with nothing mutating.
|
||||
Think of it as `gh pr list` for VERSION numbers.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Detect platform and base branch
|
||||
|
||||
Same detection as other gstack skills.
|
||||
|
||||
```bash
|
||||
BASE_BRANCH=$(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || \
|
||||
gh repo view --json defaultBranchRef -q .defaultBranchRef.name 2>/dev/null || \
|
||||
echo main)
|
||||
echo "Base branch: $BASE_BRANCH"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Read current state
|
||||
|
||||
```bash
|
||||
CURRENT_VERSION=$(cat VERSION 2>/dev/null | tr -d '[:space:]' || echo "0.0.0.0")
|
||||
git fetch origin "$BASE_BRANCH" --quiet 2>/dev/null || true
|
||||
BASE_VERSION=$(git show "origin/$BASE_BRANCH:VERSION" 2>/dev/null | tr -d '[:space:]' || echo "$CURRENT_VERSION")
|
||||
echo "origin/$BASE_BRANCH VERSION: $BASE_VERSION"
|
||||
echo "branch HEAD VERSION: $CURRENT_VERSION"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Query the queue
|
||||
|
||||
Call the util three times — once for each bump level — so the user sees what
|
||||
they'd claim for micro/patch/minor/major. Cheap (same gh call cached by bun).
|
||||
|
||||
```bash
|
||||
for LEVEL in micro patch minor major; do
|
||||
bun run bin/gstack-next-version \
|
||||
--base "$BASE_BRANCH" \
|
||||
--bump "$LEVEL" \
|
||||
--current-version "$BASE_VERSION" \
|
||||
> "/tmp/landing-$LEVEL.json" 2>/dev/null || echo '{"offline":true}' > "/tmp/landing-$LEVEL.json"
|
||||
done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Render the dashboard
|
||||
|
||||
Build a single table output. Use the `patch`-level JSON as canonical for
|
||||
queue + siblings (they're identical across bump levels; only `.version`
|
||||
differs).
|
||||
|
||||
Use `jq` to extract:
|
||||
- `.host` — github | gitlab | unknown
|
||||
- `.offline` — did the query fail?
|
||||
- `.claimed` — array of {pr, branch, version, url}
|
||||
- `.siblings` — all sibling worktrees found
|
||||
- `.active_siblings` — subset that's likely about to ship
|
||||
|
||||
Render in this exact format:
|
||||
|
||||
```
|
||||
╔══════════════════════════════════════════════════════════════════╗
|
||||
║ GSTACK LANDING REPORT ║
|
||||
╠══════════════════════════════════════════════════════════════════╣
|
||||
║ Repo: <owner/repo> ║
|
||||
║ Base: <base> @ v<base-version> ║
|
||||
║ Host: <github|gitlab|unknown> ║
|
||||
║ Status: <ONLINE|OFFLINE: queue-awareness unavailable> ║
|
||||
╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
Open PRs claiming versions on <base>:
|
||||
#1152 alpha-branch → v1.7.0.0
|
||||
#1153 beta-branch → v1.7.0.0 ⚠ collision with #1152
|
||||
#1151 gamma-branch → v1.6.5.0
|
||||
|
||||
Sibling Conductor worktrees (<workspace_root>):
|
||||
path branch VERSION last commit PR
|
||||
──────────────────────────────────────────────────────────────────────────────────
|
||||
../tokyo-v2 feat/dashboard v1.7.1.0 3h ago none ★ active
|
||||
../melbourne feat/review v1.6.0.0 12d ago none
|
||||
../osaka feat/payments v1.8.0.0 5h ago #1155
|
||||
|
||||
★ active = has VERSION ahead of base AND last commit < 24h AND no open PR.
|
||||
These are the ones likely to ship soon.
|
||||
|
||||
If you ran /ship right now, you'd claim:
|
||||
micro bump: v1.6.3.1 (queue-advance: none)
|
||||
patch bump: v1.7.1.0 (bumped past claimed 1.7.0.0)
|
||||
minor bump: v1.8.0.0 (bumped past claimed 1.7.0.0)
|
||||
major bump: v2.0.0.0 (no major collisions)
|
||||
```
|
||||
|
||||
For offline / unknown-host output, print a shorter block:
|
||||
|
||||
```
|
||||
╔══════════════════════════════════════════════════════════════════╗
|
||||
║ GSTACK LANDING REPORT ║
|
||||
╠══════════════════════════════════════════════════════════════════╣
|
||||
║ Status: OFFLINE — queue-awareness unavailable ║
|
||||
║ Reason: <offline reason from warnings> ║
|
||||
╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
Fallback: local VERSION bumps still work, but collisions cannot be detected.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Suggest next action
|
||||
|
||||
After rendering the table, suggest ONE of:
|
||||
|
||||
1. **If there are collisions in the queue** (two open PRs claim the same version):
|
||||
"⚠ Two open PRs collide on v<X>. Whoever merges second will either overwrite
|
||||
the first's CHANGELOG entry or land a duplicate. Consider asking one author
|
||||
to rerun /ship to pick up the next free slot."
|
||||
|
||||
2. **If an active sibling outranks the user's branch version:**
|
||||
"Sibling worktree <path> has v<X> committed <N>h ago and hasn't PR'd yet.
|
||||
If that work ships first, your branch will need to rebump at land time."
|
||||
|
||||
3. **If everything looks clean:**
|
||||
"Queue is clean. Next /ship will claim a slot without conflict."
|
||||
|
||||
---
|
||||
|
||||
## Plan Mode
|
||||
|
||||
PLAN MODE EXCEPTION — ALWAYS RUN. This skill is entirely read-only: no file
|
||||
writes, no git mutations, no network state changes. Safe to run in plan mode.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.10.1.0",
|
||||
"version": "1.11.0.0",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -1331,6 +1331,28 @@ git fetch origin <base> --quiet
|
||||
|
||||
Run `git diff origin/<base>` to get the full diff. This includes both committed and uncommitted changes against the latest base branch.
|
||||
|
||||
## Step 3.4: Workspace-aware queue status (advisory)
|
||||
|
||||
Check whether this PR's claimed VERSION still points at a free slot in the queue. Advisory only — never blocks review; just informs the reviewer about landing-order risk.
|
||||
|
||||
```bash
|
||||
BRANCH_VERSION=$(git show HEAD:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "")
|
||||
BASE_BRANCH=$(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || echo main)
|
||||
BASE_VERSION=$(git show origin/$BASE_BRANCH:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "")
|
||||
QUEUE_JSON=$(bun run bin/gstack-next-version \
|
||||
--base "$BASE_BRANCH" \
|
||||
--bump patch \
|
||||
--current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
|
||||
NEXT_SLOT=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
|
||||
CLAIMED_COUNT=$(echo "$QUEUE_JSON" | jq -r '.claimed | length // 0')
|
||||
OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false')
|
||||
```
|
||||
|
||||
- If `OFFLINE=true`: skip this section (no signal to report).
|
||||
- Otherwise, include ONE line in the review output: `Version claimed: v<BRANCH_VERSION>. Queue: <CLAIMED_COUNT> PR(s) ahead. <VERDICT>` where VERDICT is either `Slot free` (if `BRANCH_VERSION >= NEXT_SLOT`) or `⚠ queue moved — rerun /ship to reconcile v<BRANCH_VERSION> → v<NEXT_SLOT>`.
|
||||
|
||||
---
|
||||
|
||||
## Step 3.5: Slop scan (advisory)
|
||||
|
||||
Run a slop scan on changed files to catch AI code quality issues (empty catches,
|
||||
|
||||
@@ -74,6 +74,28 @@ git fetch origin <base> --quiet
|
||||
|
||||
Run `git diff origin/<base>` to get the full diff. This includes both committed and uncommitted changes against the latest base branch.
|
||||
|
||||
## Step 3.4: Workspace-aware queue status (advisory)
|
||||
|
||||
Check whether this PR's claimed VERSION still points at a free slot in the queue. Advisory only — never blocks review; just informs the reviewer about landing-order risk.
|
||||
|
||||
```bash
|
||||
BRANCH_VERSION=$(git show HEAD:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "")
|
||||
BASE_BRANCH=$(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || echo main)
|
||||
BASE_VERSION=$(git show origin/$BASE_BRANCH:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "")
|
||||
QUEUE_JSON=$(bun run bin/gstack-next-version \
|
||||
--base "$BASE_BRANCH" \
|
||||
--bump patch \
|
||||
--current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
|
||||
NEXT_SLOT=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
|
||||
CLAIMED_COUNT=$(echo "$QUEUE_JSON" | jq -r '.claimed | length // 0')
|
||||
OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false')
|
||||
```
|
||||
|
||||
- If `OFFLINE=true`: skip this section (no signal to report).
|
||||
- Otherwise, include ONE line in the review output: `Version claimed: v<BRANCH_VERSION>. Queue: <CLAIMED_COUNT> PR(s) ahead. <VERDICT>` where VERDICT is either `Slot free` (if `BRANCH_VERSION >= NEXT_SLOT`) or `⚠ queue moved — rerun /ship to reconcile v<BRANCH_VERSION> → v<NEXT_SLOT>`.
|
||||
|
||||
---
|
||||
|
||||
## Step 3.5: Slop scan (advisory)
|
||||
|
||||
Run a slop scan on changed files to catch AI code quality issues (empty catches,
|
||||
|
||||
@@ -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));
|
||||
+36
-8
@@ -2736,8 +2736,8 @@ fi
|
||||
Read the `STATE:` line and dispatch:
|
||||
|
||||
- **FRESH** → proceed with the bump action below (steps 1–4).
|
||||
- **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
@@ -451,8 +451,8 @@ fi
|
||||
Read the `STATE:` line and dispatch:
|
||||
|
||||
- **FRESH** → proceed with the bump action below (steps 1–4).
|
||||
- **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`)
|
||||
@@ -465,9 +465,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`.
|
||||
|
||||
@@ -768,7 +792,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.
|
||||
|
||||
@@ -836,7 +864,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
|
||||
)"
|
||||
@@ -845,7 +873,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
@@ -2736,8 +2736,8 @@ fi
|
||||
Read the `STATE:` line and dispatch:
|
||||
|
||||
- **FRESH** → proceed with the bump action below (steps 1–4).
|
||||
- **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
@@ -2351,8 +2351,8 @@ fi
|
||||
Read the `STATE:` line and dispatch:
|
||||
|
||||
- **FRESH** → proceed with the bump action below (steps 1–4).
|
||||
- **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
@@ -2727,8 +2727,8 @@ fi
|
||||
Read the `STATE:` line and dispatch:
|
||||
|
||||
- **FRESH** → proceed with the bump action below (steps 1–4).
|
||||
- **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
|
||||
)"
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user