feat(ship-queue): land --pin merges a READY PR at its own green head under merge: direct (GSTA-258 L2 PR 1)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-03 21:15:21 +00:00
1 parent f8085aa0e4
commit 60e5369daa
11 files changed
+578 -5

No files matched your search

+289
View File
@@ -0,0 +1,289 @@
/**
* gstack-ship-queue land --pr <n> --pin <sha> (docs/ship-policy.md#land-direct): B1's direct land over real fixture
* repos (bare origin, lane clone) and a gh shim that answers pr view (with mergeable, mergeStateStatus and the merge
* commit), check runs and a squash merge against the bare origin. Covers the happy path for an unstamped,
* fragment-only head, a base that moved but still merges clean, a refusal for each condition, the post-merge tree
* check, the land note, train build under merge: direct, and the policy key's validation.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { MERGE_KEY_VERSION, validatePolicy } from '../lib/ship-policy';
import { BASE_POLICY, cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture';
import { shippedBody } from './helpers/ship-queue-train-fixture';
import { persist, repoIdentity } from '../lib/ship-queue-train-record';
const REQUIRED = ['free-tests', 'Check VERSION is not stale vs queue'];
const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d', required_checks: REQUIRED }, merge: 'direct', min_gstack_version: MERGE_KEY_VERSION };
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
interface Fx { repo: FixtureRepo; env: Record<string, string>; write: (fn: (d: any) => void) => void; read: () => any }
function setup(policy: Record<string, unknown> = POLICY): Fx {
const repo = makeFixtureRepo({ policy, prefix: 'gstack-land-direct-fx-' });
repos.push(repo);
const dir = path.join(repo.root, 'gh-shim');
fs.mkdirSync(dir, { recursive: true });
const data = path.join(dir, 'data.json');
fs.writeFileSync(data, JSON.stringify({ prs: {}, checks: {}, defaultChecks: REQUIRED }));
fs.writeFileSync(path.join(dir, 'gh'), [
'#!/usr/bin/env node',
"const fs=require('fs');const {execFileSync}=require('child_process');",
'const D=process.env.LAND_SHIM_DATA, O=process.env.LAND_SHIM_ORIGIN;',
"const d=JSON.parse(fs.readFileSync(D,'utf8'));const a=process.argv.slice(2);",
"const g=(...x)=>execFileSync('git',['--git-dir',O,...x],{encoding:'utf8',timeout:30000}).trim();",
"const save=()=>fs.writeFileSync(D,JSON.stringify(d));",
"if(a[0]==='pr'&&a[1]==='view'){const p=d.prs[a[2]];if(!p){console.error('no pr');process.exit(1)}",
" let head='';try{head=g('rev-parse','refs/heads/'+p.headRefName)}catch{};",
" console.log(JSON.stringify({mergeable:'MERGEABLE',mergeStateStatus:'CLEAN',...p,headRefOid:p.headRefOid??head,mergeCommit:p.mergeSha?{oid:p.mergeSha}:null}));process.exit(0)}",
"if(a[0]==='api'){const m=/commits\\/([0-9a-f]+)\\/(check-runs|status)/.exec(a[1]);if(!m)process.exit(1);",
" if(m[2]==='status'){console.log(JSON.stringify({total_count:0,statuses:[]}));process.exit(0)}",
" const set=d.checks[m[1]]??Object.fromEntries(d.defaultChecks.map(n=>[n,'success']));",
" const runs=Object.entries(set).map(([name,c],i)=>({id:i+1,name,status:c==='pending'?'in_progress':'completed',conclusion:c==='pending'?null:c,completed_at:'2026-11-03T00:00:00Z',app:{id:1}}));",
" console.log(JSON.stringify({total_count:runs.length,check_runs:runs}));process.exit(0)}",
"if(a[0]==='pr'&&a[1]==='merge'){const p=d.prs[a[2]];const want=a[a.indexOf('--match-head-commit')+1];const head=g('rev-parse','refs/heads/'+p.headRefName);",
" if(head!==want){console.error('Head branch was modified');process.exit(1)}",
" const main=g('rev-parse','refs/heads/main');let tree=g('merge-tree','--write-tree',main,want).split('\\n')[0];",
" if(d.strayOnMerge){tree=g('rev-parse',main+'^{tree}')}",
" const sq=g('commit-tree',tree,'-p',main,'-m','squash #'+a[2]);g('update-ref','refs/heads/main',sq);",
" d.mergeArgs=a.slice(2);p.state='MERGED';p.mergeSha=sq;save();process.exit(0)}",
"console.error('gh shim: unsupported '+a.join(' '));process.exit(1);",
'',
].join('\n'), { mode: 0o755 });
const env = { PATH: dir + ':' + (process.env.PATH ?? ''), LAND_SHIM_DATA: data, LAND_SHIM_ORIGIN: repo.origin };
const read = () => JSON.parse(fs.readFileSync(data, 'utf8'));
return { repo, env, read, write: fn => { const d = read(); fn(d); fs.writeFileSync(data, JSON.stringify(d)); } };
}
/** The READY receipt gstack-ship-queue ready records (a note on origin refs/notes/gstack-trains), written from another state root. */
function markReady(fx: Fx, pr: string, head: string): void {
const prior = process.env.GSTACK_STATE_ROOT;
process.env.GSTACK_STATE_ROOT = path.join(fx.repo.root, 'ready-state');
try {
const now = new Date().toISOString();
persist(fx.repo.work, [{ schema: 1, kind: 'ready', repo_key: repoIdentity(fx.repo.work).key, pr, head, base_sha: fx.repo.git('rev-parse', 'origin/main'), ready_at: now, screen: { choice: 'no-screen', policy: null, nonce: null, run: null, merge_sha: null }, updated_at: now }]);
} finally { if (prior === undefined) delete process.env.GSTACK_STATE_ROOT; else process.env.GSTACK_STATE_ROOT = prior; }
}
/** A PR branch from origin/main with one product file and a fragment, pushed, shipped (receipt at its head) and READY. */
function openPr(fx: Fx, pr: string, opts: { edit?: (work: string) => void; shipped?: boolean; ready?: boolean; fork?: boolean } = {}): string {
const { repo } = fx;
repo.git('fetch', '-q', 'origin');
repo.git('checkout', '-q', 'origin/main', '-B', 'pr-' + pr);
fs.writeFileSync(path.join(repo.work, 'pr-' + pr + '.txt'), pr + '\n');
fs.mkdirSync(path.join(repo.work, 'changelog.d'), { recursive: true });
fs.writeFileSync(path.join(repo.work, 'changelog.d', 'pr-' + pr + '.md'), '- #' + pr + ' change\n');
opts.edit?.(repo.work);
repo.git('add', '-A');
repo.git('commit', '-q', '-m', 'pr ' + pr);
repo.git('push', '-q', '-f', 'origin', 'HEAD:refs/heads/pr-' + pr);
const head = repo.git('rev-parse', 'HEAD');
fx.write(d => { d.prs[pr] = { state: 'OPEN', baseRefName: 'main', headRefName: 'pr-' + pr, isCrossRepository: !!opts.fork, title: 'PR ' + pr, body: opts.shipped === false ? '' : shippedBody(pr, head) }; });
if (opts.ready !== false) markReady(fx, pr, head);
repo.git('checkout', '-q', '--detach', 'origin/main');
return head;
}
const land = (fx: Fx, pr: string, pin: string, extra: string[] = []) => runBin(fx.repo, 'gstack-ship-queue', ['land', '--pr', pr, '--pin', pin, '--base', 'main', ...extra], fx.env);
const remote = (fx: Fx, ref: string) => { fx.repo.git('fetch', '-q', 'origin', '+refs/heads/*:refs/remotes/origin/*'); return fx.repo.git('rev-parse', 'origin/' + ref); };
const show = (fx: Fx, rev: string) => fx.repo.git('show', rev);
const landNotes = (fx: Fx) => {
const notes = (...a: string[]) => fx.repo.git('--git-dir', fx.repo.origin, 'notes', '--ref=refs/notes/gstack-trains', ...a);
let list = '';
try { list = notes('list'); } catch { return []; }
return list.split('\n').filter(Boolean).map(l => JSON.parse(notes('show', l.split(' ')[1]!))).filter(r => r.kind === 'land');
};
/** Commit a file straight onto origin/main (another PR that landed first). */
function moveMain(fx: Fx, file: string, text: string): string {
const { repo } = fx;
repo.git('fetch', '-q', 'origin');
repo.git('checkout', '-q', '--detach', 'origin/main');
fs.writeFileSync(path.join(repo.work, file), text);
repo.git('add', '-A');
repo.git('commit', '-q', '-m', 'other change on main');
repo.git('push', '-q', 'origin', 'HEAD:refs/heads/main');
return repo.git('rev-parse', 'HEAD');
}
describe('land --pin: direct merge at the PR\'s own green head', () => {
test('happy path: an unstamped, fragment-only head merges with --squash --match-head-commit, main keeps its VERSION, and a land note is written', () => {
const fx = setup();
const base = remote(fx, 'main');
const pin = openPr(fx, '7');
const r = land(fx, '7', pin);
expect(r.err).toBe('');
expect(r.status).toBe(EXIT.ok);
expect(fx.read().mergeArgs).toEqual(['7', '--squash', '--match-head-commit', pin]);
const main = remote(fx, 'main');
expect(fx.repo.git('rev-parse', main + '^1')).toBe(base);
expect(r.out).toContain('MERGED: #7 as ' + main);
expect(r.out).toMatch(/^LANDED: origin\/main tree [0-9a-f]+ equals the pin merged onto [0-9a-f]+$/m);
expect(show(fx, main + ':VERSION')).toBe(show(fx, base + ':VERSION'));
expect(show(fx, main + ':changelog.d/pr-7.md')).toBe('- #7 change');
const notes = landNotes(fx);
expect(notes).toHaveLength(1);
expect(notes[0]).toMatchObject({ kind: 'land', pr: '7', pin, merge_sha: main, base_sha: base });
}, 90_000);
test('the base moved and the PR still merges clean: it lands as it is, on the new base, with no restamp or extra CI round', () => {
const fx = setup();
const pin = openPr(fx, '8');
const moved = moveMain(fx, 'other.txt', 'other\n');
const r = land(fx, '8', pin);
expect(r.err).toBe('');
expect(r.status).toBe(EXIT.ok);
const main = remote(fx, 'main');
expect(fx.repo.git('rev-parse', main + '^1')).toBe(moved);
expect(show(fx, main + ':other.txt')).toBe('other');
expect(show(fx, main + ':pr-8.txt')).toBe('8');
expect(fx.repo.git('rev-parse', 'origin/pr-8')).toBe(pin);
}, 90_000);
test('a rerun after GitHub merged the PR goes straight to the tree check and lands', () => {
const fx = setup();
const pin = openPr(fx, '9');
expect(land(fx, '9', pin).status).toBe(EXIT.ok);
const again = land(fx, '9', pin);
expect(again.err).toBe('');
expect(again.status).toBe(EXIT.ok);
expect(again.out).toContain('already merged');
expect(again.out).toMatch(/^LANDED: /m);
}, 90_000);
test('--dry-run checks everything and merges nothing', () => {
const fx = setup();
const base = remote(fx, 'main');
const pin = openPr(fx, '10');
const r = land(fx, '10', pin, ['--dry-run']);
expect(r.err).toBe('');
expect(r.out).toContain('DRY-RUN: nothing merged');
expect(remote(fx, 'main')).toBe(base);
}, 90_000);
});
describe('land --pin: refusals (nothing merged)', () => {
const refused = (fx: Fx, r: { status: number | null; err: string }, code: string) => {
expect(r.status).not.toBe(EXIT.ok);
expect(r.err).toContain('(' + code + ')');
expect(fx.read().mergeArgs).toBeUndefined();
};
test('QUEUE_LAND_STAMPED: a head that bumps VERSION names the one-command fix', () => {
const fx = setup();
const pin = openPr(fx, '11', { edit: w => fs.writeFileSync(path.join(w, 'VERSION'), '1.2.4.0\n') });
const r = land(fx, '11', pin);
refused(fx, r, 'QUEUE_LAND_STAMPED');
expect(r.err).toContain('git checkout $(git merge-base origin/main HEAD) -- VERSION');
}, 90_000);
test('QUEUE_LAND_STAMPED: a head that edits the CHANGELOG or the package.json version', () => {
const fx = setup();
const pin = openPr(fx, '12', {
edit: w => {
fs.appendFileSync(path.join(w, 'CHANGELOG.md'), '\n- stray\n');
const pkg = JSON.parse(fs.readFileSync(path.join(w, 'package.json'), 'utf8')); pkg.version = '9.9.9';
fs.writeFileSync(path.join(w, 'package.json'), JSON.stringify(pkg, null, 2) + '\n');
},
});
const r = land(fx, '12', pin);
refused(fx, r, 'QUEUE_LAND_STAMPED');
expect(r.err).toContain('CHANGELOG.md');
expect(r.err).toContain('package.json version');
}, 90_000);
test('a package.json dependency edit is not a stamp', () => {
const fx = setup();
const pin = openPr(fx, '13', {
edit: w => { const pkg = JSON.parse(fs.readFileSync(path.join(w, 'package.json'), 'utf8')); pkg.dependencies = { ...(pkg.dependencies ?? {}), 'left-pad': '1.3.0' }; fs.writeFileSync(path.join(w, 'package.json'), JSON.stringify(pkg, null, 2) + '\n'); },
});
const r = land(fx, '13', pin);
expect(r.err).toBe('');
expect(r.status).toBe(EXIT.ok);
}, 90_000);
test('QUEUE_LAND_PIN_MOVED: the PR head is not the pin', () => {
const fx = setup();
const pin = openPr(fx, '14');
fx.write(d => { d.prs['14'].headRefOid = 'a'.repeat(40); });
refused(fx, land(fx, '14', pin), 'QUEUE_LAND_PIN_MOVED');
}, 90_000);
test('QUEUE_RECEIPT_MISSING: no /ship receipt in the PR body', () => {
const fx = setup();
const pin = openPr(fx, '15', { shipped: false });
refused(fx, land(fx, '15', pin), 'QUEUE_RECEIPT_MISSING');
}, 90_000);
test('QUEUE_NOT_READY: no READY note at the pin', () => {
const fx = setup();
const pin = openPr(fx, '16', { ready: false });
const r = land(fx, '16', pin);
refused(fx, r, 'QUEUE_NOT_READY');
expect(r.err).toContain('gstack-ship-queue ready --pr 16');
}, 90_000);
test('QUEUE_LAND_CHECKS: a red check, a pending check, and a required check GitHub never scheduled', () => {
const fx = setup();
const pin = openPr(fx, '17');
fx.write(d => { d.checks[pin] = { 'free-tests': 'success', 'Check VERSION is not stale vs queue': 'success', other: 'failure' }; });
expect(land(fx, '17', pin).err).toMatch(/red at .*\(QUEUE_LAND_CHECKS\)/);
fx.write(d => { d.checks[pin] = { 'free-tests': 'pending', 'Check VERSION is not stale vs queue': 'success' }; });
expect(land(fx, '17', pin).err).toMatch(/pending at .*\(QUEUE_LAND_CHECKS\)/);
fx.write(d => { d.checks[pin] = { 'free-tests': 'success' }; });
const missing = land(fx, '17', pin);
refused(fx, missing, 'QUEUE_LAND_CHECKS');
expect(missing.err).toContain('Check VERSION is not stale vs queue');
}, 90_000);
test('QUEUE_LAND_NOT_CLEAN: GitHub does not report the PR mergeable and clean', () => {
const fx = setup();
const pin = openPr(fx, '18');
fx.write(d => { d.prs['18'].mergeable = 'CONFLICTING'; d.prs['18'].mergeStateStatus = 'DIRTY'; });
const r = land(fx, '18', pin);
refused(fx, r, 'QUEUE_LAND_NOT_CLEAN');
expect(r.err).toContain('DIRTY');
}, 90_000);
test('QUEUE_TRAIN_UNTRUSTED: a fork head', () => {
const fx = setup();
const pin = openPr(fx, '19', { fork: true });
refused(fx, land(fx, '19', pin), 'QUEUE_TRAIN_UNTRUSTED');
}, 90_000);
test('QUEUE_NOT_READY: the base policy merges through trains', () => {
const fx = setup({ ...POLICY, merge: 'train' });
const pin = openPr(fx, '20');
const r = land(fx, '20', pin);
refused(fx, r, 'QUEUE_NOT_READY');
expect(r.err).toContain('merge: train');
}, 90_000);
test('QUEUE_LAND_UNVERIFIED: main\'s tree after the merge is not the pin merged onto its parent', () => {
const fx = setup();
const pin = openPr(fx, '21');
fx.write(d => { d.strayOnMerge = true; });
const r = land(fx, '21', pin);
expect(r.status).not.toBe(EXIT.ok);
expect(r.err).toContain('(QUEUE_LAND_UNVERIFIED)');
expect(landNotes(fx)).toHaveLength(0);
}, 90_000);
});
describe('merge: direct | train', () => {
test('train build refuses under merge: direct and names land', () => {
const fx = setup();
const pin = openPr(fx, '22');
const r = runBin(fx.repo, 'gstack-ship-queue', ['train', 'build', '--prs', '22@' + pin, '--base', 'main'], fx.env);
expect(r.status).not.toBe(EXIT.ok);
expect(r.err).toContain('merge: direct');
expect(r.err).toContain('gstack-ship-queue land --pr <n> --pin <sha>');
}, 90_000);
test('the key validates its values and needs min_gstack_version', () => {
const errs = (o: Record<string, unknown>) => validatePolicy(o).map(e => e.path + ': ' + e.message).join('\n');
expect(errs({ merge: 'direct', min_gstack_version: MERGE_KEY_VERSION })).toBe('');
expect(errs({ merge: 'train' })).toBe('');
expect(errs({ merge: 'sideways', min_gstack_version: MERGE_KEY_VERSION })).toContain('merge: must be one of direct | train');
expect(errs({ merge: 'direct' })).toContain('introduced in gstack ' + MERGE_KEY_VERSION);
});
});