mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
* 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>
194 lines
11 KiB
TypeScript
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');
|
|
});
|
|
});
|