diff --git a/CHANGELOG.md b/CHANGELOG.md index 440611a9e..11320c122 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [1.91.196.0] - 2026-10-25 + +**`/land-and-deploy` no longer stops parallel PRs with a false "VERSION drift detected".** Step 3.4 compared the branch VERSION with the next free patch slot (`gstack-next-version --bump patch --exclude-pr`) under every ship policy. So it stopped a stamp-at-merge PR whenever a sibling shared its tentative stamp, a claim-at-open PR whenever a sibling had claimed a higher slot, and every MICRO bump even with no other PR open. Reported with a full reproduction by Evgenii Lopatin (@exGeni) in #3318. + +### Fixed +- **`land-and-deploy/SKILL.md.tmpl` Step 3.4**: reads `gstack-ship-policy show-effective` and prints one `VERSION_CHECK:` verdict. Under `stamp-at-merge` it ignores claims and checks the exact head stamp: VERSION is the base's version plus one bump, and the changelog's top two headings are that version and then the base's top entry. Otherwise it stops with `STOP stamp stale` and names the `gstack-restamp --next` command. Under `claim-at-open`, and with no policy file, it stops only on a real collision: another open PR holds the same VERSION, or the base is already at or above it. MICRO bumps and versions below a sibling's claim pass. + +### Documentation +- `docs/ship-policy.md` gains "What `/land-and-deploy` reads from it", which gives Step 3.4's stop and pass conditions for each `queue_mode`. + +### Tests +- `test/land-and-deploy-version-check.test.ts` runs the template's own Step 3.4 block against real git fixtures with the real `gstack-ship-policy` and a stubbed claim list, under bash and, when installed, zsh. It covers the three false stops from #3318 (all pass now) and the real stops: a same-VERSION claim, a base at or above the branch, a stale stamp after a sibling landed, a base that moved by MICRO under a stamp, an `[Unreleased]` entry, and a two-bump stamp. A missing VERSION, an invalid policy and unread claims are `unavailable`. + +### Gate integrity +- Step 3.4 now stops on fewer inputs: a unique VERSION below a sibling's claim, a MICRO bump, and a stamp-at-merge stamp that siblings share no longer stop. Every real collision still stops, and six mutation controls of the block (drop the collision check, the base check, the changelog binding, the one-bump rule, the policy read, or the unread-claims rule) each turn the new tests red. One new `unavailable` path: an invalid ship policy. It mirrors the existing offline path, and a base at or above the branch still stops there. +- Owner-approved budget raise (Garry, 2026-10-24: "do it"): land-and-deploy's exact size pins in `test/fixtures/skill-md-size-allowlist.json` move up by 1,084 to 1,111 characters on 11 hosts, and its parity `maxSizeRatio` in `test/helpers/carve-guards.ts` goes from 1.112 to 1.125 (measured 1.1201). No threshold, timeout, trial count or case kind changed. + ## [1.91.195.0] - 2026-10-25 **Four skill prompts stop contradicting themselves or their code.** dgrant's issues #3347, #3350, #3351 and #3352 each found a skill telling the model something wrong or ambiguous: /office-hours read a master-based repo as "no changes", /plan-design-review named a vision model the design binary no longer uses, /health gave two different GBrain scores for the same readings, and /ios-qa's demo mode claimed to override every other rule in the skill. diff --git a/VERSION b/VERSION index 419d704da..fb3143af0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.195.0 +1.91.196.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index e692c5f6d..92633de3f 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.195.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.196.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 35f88408d..7901da94a 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -478,6 +478,20 @@ which carry the workflow consequences the skill prose would otherwise spell out: The PR body's first section is the ship receipt (`gstack-ship-receipt write … --body`), written by the pr-body section of `/ship` after the body is composed. +## What `/land-and-deploy` reads from it + +Step 3.4 runs `show-effective --base ` before the merge and prints one +`VERSION_CHECK:` verdict for the PR head: + +| Policy | Stops when | Passes | +|---|---|---| +| `queue_mode: stamp-at-merge` | the head's VERSION is not the base's version plus one bump, or the changelog's top two headings at the head are not that version and then the base's top entry (`STOP stamp stale`: run `gstack-restamp --next` on the branch, push the stamp, rerun) | an exact stamp; other open PRs' tentative stamps are ignored | +| `queue_mode: claim-at-open` (and no policy file) | another open PR holds the same VERSION, or the base is already at or above it (`VERSION drift detected`: rerun `/ship`) | any unique VERSION above the base, including a MICRO bump or one below a sibling's claim | + +An invalid policy, an unreadable claim list or a missing VERSION is `unavailable`: +the skill reports it and CI's version gate is the backstop. A base at or above the +branch VERSION still stops. + ## `queue_mode: stamp-at-merge` diff --git a/land-and-deploy/SKILL.md b/land-and-deploy/SKILL.md index 62d201db9..9c6571b01 100644 --- a/land-and-deploy/SKILL.md +++ b/land-and-deploy/SKILL.md @@ -708,35 +708,45 @@ approves each), or C) stop. ## Step 3.4: VERSION drift detection (workspace-aware ship) -Check that another workspace has not claimed this PR's VERSION since `/ship`. - ```bash BRANCH_VERSION=$(git show "$PR_HEAD:VERSION" 2>/dev/null | tr -d '\r\n[:space:]') BASE_VERSION=$(git show "$BASE_SHA:VERSION" 2>/dev/null | tr -d '\r\n[:space:]') -QUEUE_JSON=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version \ - --base "$BASE_BRANCH" \ - --exclude-pr "$PR_NUMBER" \ - --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') -NO_VERSION=$(echo "$QUEUE_JSON" | jq -r '.no_version // false') +git fetch -q origin "$BASE_BRANCH" +read -r MODE CL < <(~/.claude/skills/gstack/bin/gstack-ship-policy show-effective --base "$BASE_BRANCH" --json | + jq -r 'select(.valid).policy | "\(.queue_mode) \(.changelog)"') +Q=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version --base "$BASE_BRANCH" \ + --exclude-pr "$PR_NUMBER" --bump patch --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}') +Q=$(echo "$Q" | jq -r --arg v "$BRANCH_VERSION" '[.claimed[]? | select(.version == $v).pr] as $c | if .no_version + then "NO_VERSION" elif $c != [] then "STOP also claimed by #\($c[0])" elif .offline or (.complete | not) + then "unavailable: claims unread" else "ok" end') +cl() { sed -n 's/^## \[\([^]]*\)\].*/\1/p' | head -2 | xargs; } +H=$(git show "$PR_HEAD:$CL" 2>/dev/null | cl); B=$(git show "$BASE_SHA:$CL" 2>/dev/null | cl) +V=$(awk -v a="$BASE_VERSION" -v b="$BRANCH_VERSION" 'BEGIN { r = "^[0-9]+[.][0-9]+[.][0-9]+([.][0-9]+)?$" + if (a !~ r || b !~ r || split(a, x, ".") != (n = split(b, y, "."))) { print "bad"; exit } + for (i = 1; i <= n && x[i] == y[i]; i++); if (i > n || y[i] < x[i]) { print "low"; exit } + s = y[i] == x[i] + 1; for (j = i + 1; j <= n; j++) s = s && !y[j]; print s ? "one" : "up" }') +if [ "$Q" = NO_VERSION ]; then R=$Q +elif [ "$V" = bad ]; then R="unavailable: bad version" +elif [ "$MODE" = stamp-at-merge ]; then + [ "$V" = one ] && { [ -z "$H" ] || [ "$H" = "$(echo $BRANCH_VERSION ${B%% *})" ]; } && R=ok || R="STOP stamp stale" +elif [ "$V" = low ]; then R="STOP base is at or above it" +elif [ -z "$MODE" ]; then R="unavailable: no policy" +else R=$Q; fi +echo "VERSION_CHECK: $R ($MODE: $BRANCH_VERSION, base $BASE_VERSION)" ``` -Use the existing conservative patch-level allocation; compare numeric version -components, not lexical strings. A missing/unparseable version on only one side is -unavailable, not green. - -0. `NO_VERSION=true` (no configured version source, the same signal `/ship` reads): - print `VERSION check: not applicable ()` and continue to - Step 3.5. Never expect a version-prefixed title, CHANGELOG version header or tag. -1. `OFFLINE=true`, helper failure or invalid result: report VERSION check unavailable - with the reason; continue to Step 3.5. CI's version gate is the backstop. -2. `BRANCH_VERSION >= NEXT_SLOT`: no drift; continue. -3. `BRANCH_VERSION < NEXT_SLOT`: **STOP** with "VERSION drift detected", both versions - and instructions to rerun `/ship` from the feature branch. Its ALREADY_BUMPED path - reconciles VERSION, package.json, CHANGELOG header and PR title together. Do NOT - auto-bump or merge here: duplicate versions can overwrite another branch's release notes. +- `NO_VERSION`: print `VERSION check: not applicable ()`; never + expect a version-prefixed title, CHANGELOG version header or tag. +- `unavailable`: report it; continue to Step 3.5 (CI's version gate is the backstop). +- `ok`: continue; a higher sibling claim or a MICRO bump is not drift. +- `STOP stamp stale` (stamp-at-merge: not base + one bump, or the top CHANGELOG headings + are not it then the base's): **STOP**; on the PR branch run `gstack-restamp --next --base + --pr --expect-base --allow-repo-commands`, push the stamp diff, + wait for CI, rerun. +- Other `STOP`: **STOP** with "VERSION drift detected", the verdict and: rerun `/ship` + from the feature branch (its ALREADY_BUMPED path reconciles VERSION, package.json, + CHANGELOG header and PR title). Never auto-bump or merge here: duplicate versions can + overwrite another branch's release notes. --- diff --git a/land-and-deploy/SKILL.md.tmpl b/land-and-deploy/SKILL.md.tmpl index 8b292abb2..6243389b1 100644 --- a/land-and-deploy/SKILL.md.tmpl +++ b/land-and-deploy/SKILL.md.tmpl @@ -191,35 +191,45 @@ approves each), or C) stop. ## Step 3.4: VERSION drift detection (workspace-aware ship) -Check that another workspace has not claimed this PR's VERSION since `/ship`. - ```bash BRANCH_VERSION=$(git show "$PR_HEAD:VERSION" 2>/dev/null | tr -d '\r\n[:space:]') BASE_VERSION=$(git show "$BASE_SHA:VERSION" 2>/dev/null | tr -d '\r\n[:space:]') -QUEUE_JSON=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version \ - --base "$BASE_BRANCH" \ - --exclude-pr "$PR_NUMBER" \ - --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') -NO_VERSION=$(echo "$QUEUE_JSON" | jq -r '.no_version // false') +git fetch -q origin "$BASE_BRANCH" +read -r MODE CL < <(~/.claude/skills/gstack/bin/gstack-ship-policy show-effective --base "$BASE_BRANCH" --json | + jq -r 'select(.valid).policy | "\(.queue_mode) \(.changelog)"') +Q=$(bun --no-env-file --config=/dev/null run ~/.claude/skills/gstack/bin/gstack-next-version --base "$BASE_BRANCH" \ + --exclude-pr "$PR_NUMBER" --bump patch --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}') +Q=$(echo "$Q" | jq -r --arg v "$BRANCH_VERSION" '[.claimed[]? | select(.version == $v).pr] as $c | if .no_version + then "NO_VERSION" elif $c != [] then "STOP also claimed by #\($c[0])" elif .offline or (.complete | not) + then "unavailable: claims unread" else "ok" end') +cl() { sed -n 's/^## \[\([^]]*\)\].*/\1/p' | head -2 | xargs; } +H=$(git show "$PR_HEAD:$CL" 2>/dev/null | cl); B=$(git show "$BASE_SHA:$CL" 2>/dev/null | cl) +V=$(awk -v a="$BASE_VERSION" -v b="$BRANCH_VERSION" 'BEGIN { r = "^[0-9]+[.][0-9]+[.][0-9]+([.][0-9]+)?$" + if (a !~ r || b !~ r || split(a, x, ".") != (n = split(b, y, "."))) { print "bad"; exit } + for (i = 1; i <= n && x[i] == y[i]; i++); if (i > n || y[i] < x[i]) { print "low"; exit } + s = y[i] == x[i] + 1; for (j = i + 1; j <= n; j++) s = s && !y[j]; print s ? "one" : "up" }') +if [ "$Q" = NO_VERSION ]; then R=$Q +elif [ "$V" = bad ]; then R="unavailable: bad version" +elif [ "$MODE" = stamp-at-merge ]; then + [ "$V" = one ] && { [ -z "$H" ] || [ "$H" = "$(echo $BRANCH_VERSION ${B%% *})" ]; } && R=ok || R="STOP stamp stale" +elif [ "$V" = low ]; then R="STOP base is at or above it" +elif [ -z "$MODE" ]; then R="unavailable: no policy" +else R=$Q; fi +echo "VERSION_CHECK: $R ($MODE: $BRANCH_VERSION, base $BASE_VERSION)" ``` -Use the existing conservative patch-level allocation; compare numeric version -components, not lexical strings. A missing/unparseable version on only one side is -unavailable, not green. - -0. `NO_VERSION=true` (no configured version source, the same signal `/ship` reads): - print `VERSION check: not applicable ()` and continue to - Step 3.5. Never expect a version-prefixed title, CHANGELOG version header or tag. -1. `OFFLINE=true`, helper failure or invalid result: report VERSION check unavailable - with the reason; continue to Step 3.5. CI's version gate is the backstop. -2. `BRANCH_VERSION >= NEXT_SLOT`: no drift; continue. -3. `BRANCH_VERSION < NEXT_SLOT`: **STOP** with "VERSION drift detected", both versions - and instructions to rerun `/ship` from the feature branch. Its ALREADY_BUMPED path - reconciles VERSION, package.json, CHANGELOG header and PR title together. Do NOT - auto-bump or merge here: duplicate versions can overwrite another branch's release notes. +- `NO_VERSION`: print `VERSION check: not applicable ()`; never + expect a version-prefixed title, CHANGELOG version header or tag. +- `unavailable`: report it; continue to Step 3.5 (CI's version gate is the backstop). +- `ok`: continue; a higher sibling claim or a MICRO bump is not drift. +- `STOP stamp stale` (stamp-at-merge: not base + one bump, or the top CHANGELOG headings + are not it then the base's): **STOP**; on the PR branch run `gstack-restamp --next --base + --pr --expect-base --allow-repo-commands`, push the stamp diff, + wait for CI, rerun. +- Other `STOP`: **STOP** with "VERSION drift detected", the verdict and: rerun `/ship` + from the feature branch (its ALREADY_BUMPED path reconciles VERSION, package.json, + CHANGELOG header and PR title). Never auto-bump or merge here: duplicate versions can + overwrite another branch's release notes. --- diff --git a/package.json b/package.json index 4dc3fb059..43be83ab9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.195", + "version": "1.91.196", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/fixtures/skill-md-size-allowlist.json b/test/fixtures/skill-md-size-allowlist.json index 9083336de..cd196ee0d 100644 --- a/test/fixtures/skill-md-size-allowlist.json +++ b/test/fixtures/skill-md-size-allowlist.json @@ -5,7 +5,7 @@ "agy/gstack-autoplan": 130504, "agy/gstack-design-consultation": 108184, "agy/gstack-design-review": 143528, - "agy/gstack-land-and-deploy": 116611, + "agy/gstack-land-and-deploy": 117722, "agy/gstack-plan-design-review": 140013, "agy/gstack-plan-devex-review": 135760, "agy/gstack-plan-eng-review": 152701, @@ -14,7 +14,7 @@ "codex/gstack-autoplan": 130628, "codex/gstack-design-consultation": 109452, "codex/gstack-design-review": 145028, - "codex/gstack-land-and-deploy": 121188, + "codex/gstack-land-and-deploy": 122272, "codex/gstack-office-hours": 102339, "codex/gstack-plan-design-review": 142466, "codex/gstack-plan-devex-review": 138649, @@ -24,7 +24,7 @@ "copilot/gstack-codex": 100235, "copilot/gstack-design-consultation": 109536, "copilot/gstack-design-review": 144833, - "copilot/gstack-land-and-deploy": 117881, + "copilot/gstack-land-and-deploy": 118977, "copilot/gstack-office-hours": 100495, "copilot/gstack-plan-design-review": 140884, "copilot/gstack-plan-devex-review": 136912, @@ -33,7 +33,7 @@ "cursor/gstack-autoplan": 129278, "cursor/gstack-design-consultation": 107222, "cursor/gstack-design-review": 142507, - "cursor/gstack-land-and-deploy": 115550, + "cursor/gstack-land-and-deploy": 116645, "cursor/gstack-plan-design-review": 138968, "cursor/gstack-plan-devex-review": 134591, "cursor/gstack-plan-eng-review": 151555, @@ -42,7 +42,7 @@ "factory/gstack-codex": 100655, "factory/gstack-design-consultation": 108670, "factory/gstack-design-review": 144297, - "factory/gstack-land-and-deploy": 119733, + "factory/gstack-land-and-deploy": 120817, "factory/gstack-office-hours": 101374, "factory/gstack-plan-design-review": 141749, "factory/gstack-plan-devex-review": 137960, @@ -50,34 +50,34 @@ "factory/gstack-review": 144529, "gbrain/gstack-autoplan": 129815, "gbrain/gstack-design-review": 132842, - "gbrain/gstack-land-and-deploy": 116072, + "gbrain/gstack-land-and-deploy": 117167, "gbrain/gstack-plan-design-review": 127855, "gbrain/gstack-plan-devex-review": 115129, "gbrain/gstack-plan-eng-review": 134287, "hermes/gstack-autoplan": 129905, "hermes/gstack-design-review": 132949, - "hermes/gstack-land-and-deploy": 116174, + "hermes/gstack-land-and-deploy": 117269, "hermes/gstack-plan-design-review": 127964, "hermes/gstack-plan-devex-review": 115215, "hermes/gstack-plan-eng-review": 134372, "kiro/gstack-autoplan": 129040, "kiro/gstack-design-consultation": 107064, "kiro/gstack-design-review": 142321, - "kiro/gstack-land-and-deploy": 115440, + "kiro/gstack-land-and-deploy": 116533, "kiro/gstack-plan-design-review": 138808, "kiro/gstack-plan-devex-review": 134431, "kiro/gstack-plan-eng-review": 151405, "kiro/gstack-review": 140562, "openclaw/gstack-autoplan": 129520, "openclaw/gstack-design-review": 131647, - "openclaw/gstack-land-and-deploy": 115660, + "openclaw/gstack-land-and-deploy": 116757, "openclaw/gstack-plan-design-review": 126706, "openclaw/gstack-plan-devex-review": 113968, "openclaw/gstack-plan-eng-review": 132684, "opencode/gstack-autoplan": 131640, "opencode/gstack-design-consultation": 109357, "opencode/gstack-design-review": 144705, - "opencode/gstack-land-and-deploy": 117658, + "opencode/gstack-land-and-deploy": 118762, "opencode/gstack-office-hours": 100329, "opencode/gstack-plan-design-review": 140738, "opencode/gstack-plan-devex-review": 136812, @@ -86,7 +86,7 @@ "slate/gstack-autoplan": 129159, "slate/gstack-design-consultation": 107143, "slate/gstack-design-review": 142414, - "slate/gstack-land-and-deploy": 115495, + "slate/gstack-land-and-deploy": 116589, "slate/gstack-plan-design-review": 138888, "slate/gstack-plan-devex-review": 134511, "slate/gstack-plan-eng-review": 151480, diff --git a/test/helpers/carve-guards.ts b/test/helpers/carve-guards.ts index 4c250a708..ff3ff4203 100644 --- a/test/helpers/carve-guards.ts +++ b/test/helpers/carve-guards.ts @@ -539,7 +539,7 @@ do not launch the downstream skill or open a browser.`, }, behavioral: 'prompt', maxSkeletonBytes: 74_500, // + Aside browser contract for Step 7 canary ({{ASIDE_SETUP}}); measured 73_523 - maxSizeRatio: 1.1120, // + v1.81 Aside contract + gstack-browser fallback block; measured 1.077. + both untrusted-content marker formats in the browser fallback (W7f) + the readiness gate's project test command and live eval-store paths (s06 H6/H7); measured 1.1076 (2026-10-02). Re-measured 1.0943 (2026-10-03); cap = measured + 0.005 headroom. + Oct 7 wave B6: Aside rules 1, 4, 6 and 7 (offer a target-origin tab before openTab, a second sign-in wall means a tab- or URL-bound session, no early-return hint, fast [ok without the sentinel is an abort); measured 115,226 / 104,578 = 1.1018 (2026-10-07). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10). + maxSizeRatio: 1.125, // + #3318 Step 3.4 reads the ship policy and prints one VERSION_CHECK verdict (owner-approved raise, Garry 2026-10-24: "do it"); measured 1.1201 (2026-10-24). Before: 1.1120. + v1.81 Aside contract + gstack-browser fallback block; measured 1.077. + both untrusted-content marker formats in the browser fallback (W7f) + the readiness gate's project test command and live eval-store paths (s06 H6/H7); measured 1.1076 (2026-10-02). Re-measured 1.0943 (2026-10-03); cap = measured + 0.005 headroom. + Oct 7 wave B6: Aside rules 1, 4, 6 and 7 (offer a target-origin tab before openTab, a second sign-in wall means a tab- or URL-bound session, no early-return hint, fast [ok without the sentinel is an abort); measured 115,226 / 104,578 = 1.1018 (2026-10-07). + Honest Work wave merged (W2/W3 gate integrity in review/ship, W4 kernel + DONE line in every tier-2+ skill, W5/W7 in spec/ship): re-measured on the integrated tree (2026-10-10). minUnionBytes: 91_000, // Phase 4 wave 1; estimated union ~94.9KB mustContain: ['readiness', 'merge', 'canary', 'revert', 'staging'], }, diff --git a/test/land-and-deploy-version-check.test.ts b/test/land-and-deploy-version-check.test.ts new file mode 100644 index 000000000..687bbc686 --- /dev/null +++ b/test/land-and-deploy-version-check.test.ts @@ -0,0 +1,193 @@ +/** + * /land-and-deploy Step 3.4 VERSION check (#3318). The old step compared the + * branch VERSION with the next free patch slot, so it stopped stamp-at-merge + * PRs that share a tentative stamp, a claim-at-open PR whose sibling claimed a + * higher slot, and every MICRO bump. These tests run the template's own block + * against a real git fixture and the real gstack-ship-policy, with a stubbed + * gstack-next-version reply, and pin each verdict, including the real stops. + */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const ROOT = join(import.meta.dir, '..'); +const STUB = `process.stdout.write(process.env.FAKE_QUEUE_JSON ?? ''); +process.exit(Number(process.env.FAKE_QUEUE_CODE ?? 0)); +`; + +function step34(): string { + const tmpl = readFileSync(join(ROOT, 'land-and-deploy', 'SKILL.md.tmpl'), 'utf8'); + const step = tmpl.slice(tmpl.indexOf('## Step 3.4:'), tmpl.indexOf('{{SECTION:readiness-gate}}')); + const blocks = [...step.matchAll(/```bash\n([\s\S]*?)\n```/g)].map(m => m[1]); + expect(blocks).toHaveLength(1); + return blocks[0]; +} + +let root: string; +let dir: string; +let repo: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'lad-version-')); + writeFileSync(join(root, 'next-version.ts'), STUB); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +function git(...args: string[]): string { + const r = spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@example.com', ...args], { cwd: repo, encoding: 'utf8', timeout: 15_000 }); + if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`); + return r.stdout.trim(); +} + +const changelog = (...versions: string[]) => `# Changelog\n\n${versions.map(v => `## [${v}] - 2026-10-24\n\n- entry\n`).join('\n')}`; + +interface Tree { version?: string; changelog?: string; policy?: string } +function commit(tree: Tree, message: string): string { + for (const [file, body] of [['VERSION', tree.version], ['CHANGELOG.md', tree.changelog], ['.gstack/ship-policy.json', tree.policy]] as const) { + if (body === undefined) continue; + mkdirSync(join(repo, file, '..'), { recursive: true }); + writeFileSync(join(repo, file), body.endsWith('\n') ? body : `${body}\n`); + } + git('add', '-A'); + git('commit', '-q', '--allow-empty', '-m', message); + return git('rev-parse', 'HEAD'); +} + +interface Case { + mode?: 'stamp-at-merge' | 'claim-at-open' | 'none' | 'invalid'; + base: Tree; + head: Tree; + queue?: object | string; + queueCode?: number; + shell?: 'bash' | 'zsh'; +} +function verdict(c: Case): string { + dir = mkdtempSync(join(root, 'case-')); + repo = join(dir, 'repo'); + mkdirSync(repo); + git('init', '-q', '-b', 'main'); + const policy = c.mode === 'none' || c.mode === undefined ? undefined + : c.mode === 'invalid' ? '{"queue_mode": ' + : JSON.stringify({ queue_mode: c.mode, bump: 'patch' }); + const baseSha = commit({ ...c.base, policy }, 'base'); + spawnSync('git', ['init', '-q', '--bare', join(dir, 'origin.git')], { timeout: 15_000 }); + git('remote', 'add', 'origin', join(dir, 'origin.git')); + git('push', '-q', 'origin', 'main'); + git('checkout', '-q', '-b', 'feature'); + const head = commit(c.head, 'head'); + const queue = c.queue ?? { offline: false, claimed: [], complete: true }; + const block = step34() + .replaceAll('~/.claude/skills/gstack/bin/gstack-ship-policy', join(ROOT, 'bin', 'gstack-ship-policy')) + .replaceAll('~/.claude/skills/gstack/bin/gstack-next-version', join(root, 'next-version.ts')); + const r = spawnSync(c.shell ?? 'bash', ['-c', block], { + cwd: repo, + encoding: 'utf8', + timeout: 60_000, + env: { + ...process.env, + PR_HEAD: head, BASE_SHA: baseSha, BASE_BRANCH: 'main', PR_NUMBER: '7', + FAKE_QUEUE_JSON: typeof queue === 'string' ? queue : JSON.stringify(queue), + FAKE_QUEUE_CODE: String(c.queueCode ?? 0), + }, + }); + const line = r.stdout.split('\n').find(l => l.startsWith('VERSION_CHECK: ')); + if (!line) throw new Error(`no VERSION_CHECK line (exit ${r.status}):\n${r.stdout}\n${r.stderr}`); + return line; +} + +const claim = (pr: number, version: string) => ({ pr, branch: `b${pr}`, version }); +const BASE: Tree = { version: '1.91.186.0', changelog: changelog('1.91.186.0') }; +const stamped = (version: string, below = '1.91.186.0'): Tree => ({ version, changelog: changelog(version, below) }); + +describe('stamp-at-merge: verify the exact head stamp, ignore claims', () => { + test('a sibling sharing the tentative stamp no longer stops the PR', () => { + expect(verdict({ mode: 'stamp-at-merge', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0')], complete: true } })) + .toBe('VERSION_CHECK: ok (stamp-at-merge: 1.91.187.0, base 1.91.186.0)'); + }); + + test('a numeric one-bump stamp passes (9 -> 10)', () => { + expect(verdict({ mode: 'stamp-at-merge', base: { version: '1.91.9.0', changelog: changelog('1.91.9.0') }, head: { version: '1.91.10.0', changelog: changelog('1.91.10.0', '1.91.9.0') } })).toStartWith('VERSION_CHECK: ok (stamp-at-merge:'); + }); + + test('a MICRO stamp is one bump and passes', () => { + expect(verdict({ mode: 'stamp-at-merge', base: BASE, head: stamped('1.91.186.1') })).toStartWith('VERSION_CHECK: ok (stamp-at-merge:'); + }); + + test('claims offline do not matter under stamp-at-merge', () => { + expect(verdict({ mode: 'stamp-at-merge', base: BASE, head: stamped('1.91.187.0'), queue: '', queueCode: 1 })).toStartWith('VERSION_CHECK: ok (stamp-at-merge:'); + }); + + test('stops when the base already reached the stamp (a sibling landed first)', () => { + expect(verdict({ mode: 'stamp-at-merge', base: stamped('1.91.187.0'), head: stamped('1.91.187.0') })) + .toBe('VERSION_CHECK: STOP stamp stale (stamp-at-merge: 1.91.187.0, base 1.91.187.0)'); + }); + + test('stops when the base moved under a stamp that is still one bump above it', () => { + expect(verdict({ mode: 'stamp-at-merge', base: stamped('1.91.186.1'), head: stamped('1.91.187.0') })).toStartWith('VERSION_CHECK: STOP stamp stale'); + }); + + test('stops on an unstamped [Unreleased] entry and on a stamp more than one bump up', () => { + expect(verdict({ mode: 'stamp-at-merge', base: BASE, head: { version: '1.91.187.0', changelog: changelog('Unreleased', '1.91.186.0') } })).toStartWith('VERSION_CHECK: STOP stamp stale'); + expect(verdict({ mode: 'stamp-at-merge', base: BASE, head: stamped('1.91.188.0') })).toStartWith('VERSION_CHECK: STOP stamp stale'); + }); +}); + +describe('claim-at-open: stop only on a real collision', () => { + test('versions compare numerically, and 3-digit semver works', () => { + expect(verdict({ mode: 'claim-at-open', base: { version: '1.91.9.0' }, head: { version: '1.91.10.0' } })).toStartWith('VERSION_CHECK: ok (claim-at-open:'); + expect(verdict({ mode: 'claim-at-open', base: { version: '1.91.10.0' }, head: { version: '1.91.9.0' } })).toStartWith('VERSION_CHECK: STOP base is at or above it'); + expect(verdict({ mode: 'claim-at-open', base: { version: '2.14.9' }, head: { version: '2.14.10' } })).toStartWith('VERSION_CHECK: ok (claim-at-open:'); + expect(verdict({ mode: 'claim-at-open', base: { version: '2.14.9' }, head: { version: '2.14.9.1' } })).toStartWith('VERSION_CHECK: unavailable: bad version'); + }); + + test('a sibling holding a higher claim no longer stops the PR', () => { + expect(verdict({ mode: 'claim-at-open', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.188.0')], complete: true } })) + .toBe('VERSION_CHECK: ok (claim-at-open: 1.91.187.0, base 1.91.186.0)'); + }); + + test('a MICRO bump passes', () => { + expect(verdict({ mode: 'claim-at-open', base: BASE, head: stamped('1.91.186.1') })).toStartWith('VERSION_CHECK: ok (claim-at-open:'); + }); + + test('stops when another open PR holds the same VERSION', () => { + expect(verdict({ mode: 'claim-at-open', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0'), claim(9, '1.91.188.0')], complete: true } })) + .toBe('VERSION_CHECK: STOP also claimed by #8 (claim-at-open: 1.91.187.0, base 1.91.186.0)'); + }); + + test('stops when the base is at or above the branch VERSION', () => { + expect(verdict({ mode: 'claim-at-open', base: stamped('1.91.187.0'), head: stamped('1.91.187.0') })).toStartWith('VERSION_CHECK: STOP base is at or above it'); + expect(verdict({ mode: 'claim-at-open', base: stamped('1.91.188.0'), head: stamped('1.91.187.0') })).toStartWith('VERSION_CHECK: STOP base is at or above it'); + }); + + test('a repo with no policy file gets the claim-at-open default', () => { + expect(verdict({ mode: 'none', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0')], complete: true } })).toStartWith('VERSION_CHECK: STOP also claimed by #8'); + }); + + test('offline claims and unreadable claims are unavailable, but a known collision still stops', () => { + expect(verdict({ mode: 'claim-at-open', base: BASE, head: stamped('1.91.187.0'), queue: { offline: true } })).toStartWith('VERSION_CHECK: unavailable: claims unread'); + expect(verdict({ mode: 'claim-at-open', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [], unknown_claims: [9], complete: false } })).toStartWith('VERSION_CHECK: unavailable: claims unread'); + expect(verdict({ mode: 'claim-at-open', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0')], unknown_claims: [9], complete: false } })).toStartWith('VERSION_CHECK: STOP also claimed by #8'); + }); +}); + +describe('either mode', () => { + test.skipIf(!Bun.which('zsh'))('the block gives the same verdicts under zsh', () => { + expect(verdict({ shell: 'zsh', mode: 'stamp-at-merge', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0')], complete: true } })).toStartWith('VERSION_CHECK: ok (stamp-at-merge:'); + expect(verdict({ shell: 'zsh', mode: 'stamp-at-merge', base: stamped('1.91.186.1'), head: stamped('1.91.187.0') })).toStartWith('VERSION_CHECK: STOP stamp stale'); + expect(verdict({ shell: 'zsh', mode: 'claim-at-open', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0')], complete: true } })).toStartWith('VERSION_CHECK: STOP also claimed by #8'); + }); + + test('no configured version source is not applicable', () => { + expect(verdict({ mode: 'stamp-at-merge', base: {}, head: {}, queue: { no_version: true, version: null } })).toStartWith('VERSION_CHECK: NO_VERSION'); + }); + + test('a VERSION missing on one side is unavailable, not green', () => { + expect(verdict({ mode: 'claim-at-open', base: { changelog: changelog('1.91.186.0') }, head: stamped('1.91.187.0') })).toStartWith('VERSION_CHECK: unavailable: bad version'); + }); + + test('an invalid policy is unavailable, but a base at or above the branch still stops', () => { + expect(verdict({ mode: 'invalid', base: BASE, head: stamped('1.91.187.0'), queue: { offline: false, claimed: [claim(8, '1.91.187.0')], complete: true } })).toStartWith('VERSION_CHECK: unavailable: no policy'); + expect(verdict({ mode: 'invalid', base: stamped('1.91.187.0'), head: stamped('1.91.187.0') })).toStartWith('VERSION_CHECK: STOP base is at or above it'); + }); +});