Files
gstack/test/land-and-deploy-version-check.test.ts
Garry Tanandcapy-ai[bot] 080b752140 v1.91.196.0 fix(land-and-deploy): Step 3.4 reads the ship policy and stops only on a real VERSION collision (#3318) (#3379)
* 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>

* docs(ship-policy): what /land-and-deploy Step 3.4 reads from the policy (#3318)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): Unreleased entry for #3318 /land-and-deploy Step 3.4

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(size): raise land-and-deploy size pins and parity cap for #3318 Step 3.4 (owner-approved)

Garry, 2026-10-24: "do it". Pins set to the measured sizes on 11 hosts
(+1,084 to +1,111 chars); maxSizeRatio 1.112 -> 1.125 (measured 1.1201).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-25 05:49:11 +00:00

194 lines
11 KiB
TypeScript

/**
* /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');
});
});