fix(land-and-deploy): Step 3.4 reads the ship policy and stops only on a real VERSION collision (#3318)

Under stamp-at-merge, verify the exact head stamp (base + one bump, changelog
headings bound to it) and ignore claims. Under claim-at-open, stop only when
another open PR holds the same VERSION or the base is at or above it. MICRO
bumps and versions below a sibling's claim pass.

Reported-by: Evgenii Lopatin (@exGeni)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-24 20:28:53 +00:00
1 parent 00100f1598
commit 752e6dc36d
3 files changed
+261 -48

No files matched your search

+34 -24
View File
@@ -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 (<version_source.reason>)` 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 (<version_source.reason>)`; 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
<base> --pr <n> --expect-base <BASE_SHA> --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.
---
+34 -24
View File
@@ -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 (<version_source.reason>)` 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 (<version_source.reason>)`; 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
<base> --pr <n> --expect-base <BASE_SHA> --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.
---
+193
View File
@@ -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');
});
});