mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 23:14:33 +01:00
feat(ship-queue): train build stamps S_i under fragments; train land publishes S_i exactly; backstop covers exact trees (PR B)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
dc309c793e
commit
ead4a7c273
11 files changed
+321
-19
No files matched your search
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Stamped trains (changelog_fragments; GSTA-154 PR B): train build makes S_1..S_k beside T_1..T_k and gates
|
||||
* S_k; train land publishes each slot's S_i exactly (a tool-made stamp commit on [pin, main], pushed under
|
||||
* the pin's lease, labelled evals-no-debounce), confirms main is S_i, and freezes on any other main.
|
||||
* Fixture repos with a bare origin and the train gh shim, wrapped for pr edit, pr merge and the PR's checks.
|
||||
*/
|
||||
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 { cleanup } from './helpers/restamp-fixture';
|
||||
import {
|
||||
GREEN_GATE, TRAIN_POLICY, line, nonceOf, openPr, queue, readyReceipt, trainFixture, trainJson, type TrainFixture,
|
||||
} from './helpers/ship-queue-train-fixture';
|
||||
|
||||
const POLICY = { ...TRAIN_POLICY, release_tool: null, release_outputs: [], changelog_fragments: { dir: 'changelog.d' }, min_gstack_version: '1.92.0.0' };
|
||||
const fixtures: TrainFixture[] = [];
|
||||
afterEach(() => { while (fixtures.length) cleanup(fixtures.pop()!.repo); });
|
||||
|
||||
/** pr view reads the head from origin's branch; pr edit and pr merge act on origin; any commit without recorded CI reads green. */
|
||||
function wrap(fx: TrainFixture): void {
|
||||
const dir = path.join(fx.repo.root, 'gh-land-wrap');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const shim = path.join(fx.repo.root, 'gh-train-shim', 'shim.js');
|
||||
fs.writeFileSync(path.join(dir, 'gh'), [
|
||||
'#!/usr/bin/env node',
|
||||
"const fs=require('fs');const cp=require('child_process');const D=process.env.LAND_WRAP_DATA;const a=process.argv.slice(2);",
|
||||
"const d=JSON.parse(fs.readFileSync(D,'utf8'));const save=()=>fs.writeFileSync(D,JSON.stringify(d));",
|
||||
"const g=(...x)=>cp.execFileSync('git',['--git-dir',d.origin,...x],{encoding:'utf8'}).trim();",
|
||||
"const rev=r=>{try{return g('rev-parse','--verify','--quiet',r)}catch{return ''}};",
|
||||
"if(a[0]==='pr'&&a[1]==='view'&&d.prs[a[2]]){const p=d.prs[a[2]];const h=rev('refs/heads/'+p.headRefName);if(h)p.headRefOid=h;save();console.log(JSON.stringify(p));process.exit(0)}",
|
||||
"if(a[0]==='pr'&&a[1]==='edit'){(d.labels=d.labels||{})[a[2]]=a[a.indexOf('--add-label')+1];save();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];if(rev('refs/heads/'+p.headRefName)!==want){console.error('Head branch was modified');process.exit(1)}",
|
||||
" const before=rev('main');let tree=rev(want+'^{tree}');",
|
||||
" if(d.outsideOnMerge){const blob=cp.execFileSync('git',['--git-dir',d.origin,'hash-object','-w','--stdin'],{input:'outside\\n',encoding:'utf8'}).trim();",
|
||||
" const ls=g('ls-tree',before+'^{tree}')+'\\n100644 blob '+blob+'\\toutside.txt';const ot=cp.execFileSync('git',['--git-dir',d.origin,'mktree'],{input:ls+'\\n',encoding:'utf8'}).trim();",
|
||||
" g('update-ref','refs/heads/main',g('commit-tree',ot,'-p',before,'-m','outside'));tree=g('merge-tree','--write-tree','--merge-base='+before,rev('main'),want).split('\\n')[0];d.outsideOnMerge=false}",
|
||||
" g('update-ref','refs/heads/main',g('commit-tree',tree,'-p',rev('main'),'-m','squash #'+a[2]));p.state='MERGED';save();process.exit(0)}",
|
||||
"const paged=/commits\\/([0-9a-f]+)\\/(check-runs|status)\\?.*page=(\\d+)/.exec(a[1]||'');",
|
||||
"if(a[0]==='api'&&paged&&!(d.ci||{})[paged[1]]&&!((d.statuses||{})[paged[1]])){const list=paged[2]==='check-runs'?[{name:'free-tests',status:'completed',conclusion:'success',id:1,completed_at:'2026-10-26T00:00:00Z'}]:[];const key=paged[2]==='check-runs'?'check_runs':'statuses';console.log(JSON.stringify(paged[3]==='1'?{total_count:list.length,state:'success',[key]:list}:{total_count:list.length,[key]:[]}));process.exit(0)}",
|
||||
`const r=cp.spawnSync('node',[${JSON.stringify(shim)},D,...a],{stdio:'inherit'});process.exit(r.status??1);`,
|
||||
'',
|
||||
].join('\n'), { mode: 0o755 });
|
||||
fx.env.PATH = `${dir}:${fx.env.PATH}`;
|
||||
(fx.env as Record<string, string>).LAND_WRAP_DATA = fx.dataFile;
|
||||
}
|
||||
|
||||
function fragmentPr(fx: TrainFixture, pr: string, fragment = `- #${pr} change\n`): string {
|
||||
const head = openPr(fx, pr, { file: `changelog.d/pr-${pr}.md`, text: fragment, changelog: false });
|
||||
fx.repo.git('push', '-q', 'origin', `${head}:refs/heads/pr-${pr}`);
|
||||
readyReceipt(fx, pr, head);
|
||||
return head;
|
||||
}
|
||||
function stampedTrain() {
|
||||
const fx = trainFixture(POLICY, 'gstack-train-land-'); fixtures.push(fx); wrap(fx);
|
||||
const h1 = fragmentPr(fx, '1');
|
||||
const h2 = fragmentPr(fx, '2', '---\nbump: minor\n---\n- #2 change\n');
|
||||
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
|
||||
const built = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`]);
|
||||
const nonce = nonceOf(built.out);
|
||||
return { fx, h1, h2, built, nonce };
|
||||
}
|
||||
const land = (fx: TrainFixture, nonce: string, pr: string) => queue(fx, ['train', 'land', '--train', nonce, '--pr', pr, '--no-wait']);
|
||||
const originMain = (fx: TrainFixture) => fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'main');
|
||||
|
||||
describe('stamped trains: build and land', () => {
|
||||
test('build records S1, S2 and gates S2; land publishes each slot exactly, with the label, and records the merge', () => {
|
||||
const { fx, h1, h2, built, nonce } = stampedTrain();
|
||||
expect(built.status, built.err).toBe(EXIT.ok);
|
||||
expect(built.out).toMatch(/^S1: #1 v1\.2\.4\.0 [0-9a-f]{12} /m);
|
||||
expect(built.out).toMatch(/^S2: #2 v1\.3\.0\.0 [0-9a-f]{12} /m);
|
||||
const t = trainJson(fx, nonce);
|
||||
expect(t.stamped.map((s: any) => [s.pr, s.version])).toEqual([['1', '1.2.4.0'], ['2', '1.3.0.0']]);
|
||||
expect(t.head).toBe(t.stamped[1].commit);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
|
||||
const verify = queue(fx, ['verify', '--pr', '1', '--train', nonce]);
|
||||
expect(verify.err).toMatch(/has stamped prefixes.*\(QUEUE_GATE_MISSING\)/);
|
||||
expect(line(verify.out, 'NEXT:')).toBe(`NEXT: gstack-ship-queue train land --train ${nonce} --pr 1`);
|
||||
const early = land(fx, nonce, '2');
|
||||
expect(early.err).toMatch(/is neither S1 nor S2 exactly.*not frozen.*\(QUEUE_TRAIN_TREE_MISMATCH\)/);
|
||||
const pushed = land(fx, nonce, '1');
|
||||
expect(pushed.out, pushed.err).toContain('LABEL: evals-no-debounce added');
|
||||
const stamp = /^PUSHED: ([0-9a-f]{40})/m.exec(pushed.out)![1]!;
|
||||
expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(stamp);
|
||||
expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-list', '--parents', '-n', '1', stamp).split(' ').slice(1)).toEqual([h1, originMain(fx)]);
|
||||
expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', stamp + '^{tree}')).toBe(t.stamped[0].tree);
|
||||
const merged = land(fx, nonce, '1');
|
||||
expect(merged.status, merged.err).toBe(EXIT.ok);
|
||||
expect(merged.out).toContain("main's tree is S1 exactly");
|
||||
expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'main^{tree}')).toBe(t.stamped[0].tree);
|
||||
land(fx, nonce, '2');
|
||||
const second = land(fx, nonce, '2');
|
||||
expect(second.status, second.err).toBe(EXIT.ok);
|
||||
expect(fx.repo.git('--git-dir', fx.repo.origin, 'show', 'main:VERSION')).toBe('1.3.0.0');
|
||||
expect(trainJson(fx, nonce)).toMatchObject({ state: 'merged' });
|
||||
expect(fx.read().prs['2']).toMatchObject({ state: 'MERGED' });
|
||||
expect((fx.read() as any).labels).toEqual({ 1: 'evals-no-debounce', 2: 'evals-no-debounce' });
|
||||
void h2;
|
||||
}, 300_000);
|
||||
|
||||
test('a foreign push to the PR branch is never overwritten; a merge outside the train freezes the queue', () => {
|
||||
const { fx, nonce } = stampedTrain();
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
fx.repo.git('push', '-q', '-f', 'origin', 'origin/main:refs/heads/pr-1');
|
||||
const moved = land(fx, nonce, '1');
|
||||
expect(moved.err).toContain('(QUEUE_TRAIN_MOVED)');
|
||||
expect(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'refs/heads/pr-1')).toBe(fx.repo.git('--git-dir', fx.repo.origin, 'rev-parse', 'main'));
|
||||
|
||||
const second = stampedTrain();
|
||||
expect(queue(second.fx, ['train', 'verdict', '--train', second.nonce]).status).toBe(EXIT.ok);
|
||||
land(second.fx, second.nonce, '1');
|
||||
expect(land(second.fx, second.nonce, '1').status).toBe(EXIT.ok);
|
||||
land(second.fx, second.nonce, '2');
|
||||
second.fx.write(d => { (d as any).outsideOnMerge = true; });
|
||||
const drift = land(second.fx, second.nonce, '2');
|
||||
expect(drift.err).toContain('(QUEUE_TRAIN_TREE_MISMATCH)');
|
||||
expect(line(drift.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1/);
|
||||
}, 300_000);
|
||||
|
||||
test('a plain train refuses train land and keeps the slot-stamp path', () => {
|
||||
const fx = trainFixture(TRAIN_POLICY, 'gstack-train-land-'); fixtures.push(fx); wrap(fx);
|
||||
const h1 = openPr(fx, '1');
|
||||
readyReceipt(fx, '1', h1);
|
||||
fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; });
|
||||
const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `1@${h1}`, '--urgent']).out);
|
||||
expect(trainJson(fx, nonce).stamped).toBeUndefined();
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
const r = land(fx, nonce, '1');
|
||||
expect(r.err).toMatch(/was built without stamped prefixes.*\(QUEUE_GATE_MISSING\)/);
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -223,3 +223,50 @@ describe('backstop: debounce while a train merges', () => {
|
||||
expect(dispatches()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('backstop: a stamped train (changelog_fragments) covers only exact trees', () => {
|
||||
test('main equal to S1 is covered as S1; the same tree with another VERSION is gated', () => {
|
||||
const fx = trainFixture();
|
||||
const { repo, main0, prs } = fx;
|
||||
let parent = main0;
|
||||
const stamped = prs.slice(0, 2).map((head, i) => {
|
||||
repo.git('checkout', '-q', '-f', '--detach', parent);
|
||||
repo.git('merge', '-q', '--no-ff', '--no-commit', head);
|
||||
fs.writeFileSync(path.join(repo.work, 'VERSION'), `1.2.${4 + i}.0\n`);
|
||||
repo.git('add', '-A');
|
||||
parent = repo.git('commit-tree', repo.git('write-tree'), '-p', parent, '-p', head, '-m', `train S${i + 1}: merge #${101 + i} at ${head.slice(0, 12)} v1.2.${4 + i}.0`);
|
||||
repo.git('merge', '--abort');
|
||||
return parent;
|
||||
});
|
||||
repo.git('checkout', '-q', '-f', 'main');
|
||||
const prefixes = trainPrefixes(gitIn(repo.work), parent, repo.work, loadPolicyFromHead(repo.work).policy);
|
||||
if (!Array.isArray(prefixes)) throw new Error(prefixes.error);
|
||||
expect(prefixes.map(p => [p.index, p.exact])).toEqual([[1, true], [2, true]]);
|
||||
const record = { ...fx.record, head_sha: parent, tree: repo.git('rev-parse', `${parent}^{tree}`), prefixes };
|
||||
const push = (tree: string) => {
|
||||
const sha = repo.git('commit-tree', tree, '-p', repo.git('rev-parse', 'main'), '-m', 'squash');
|
||||
repo.git('update-ref', 'refs/heads/main', sha);
|
||||
repo.git('push', '-q', 'origin', 'main');
|
||||
return sha;
|
||||
};
|
||||
const s1 = push(repo.git('rev-parse', `${stamped[0]}^{tree}`));
|
||||
const covered = backstop({ ...fx, record }, s1, fakeGh({ ...fx, record }, { record }).gh);
|
||||
expect(covered.action, covered.lines.join('\n')).toBe('covered');
|
||||
expect(covered.lines.at(-1)).toContain(`is S1 of green train ${TRAIN_NONCE} (run 900, exact tree`);
|
||||
repo.git('checkout', '-q', '-f', '--detach', stamped[0]!);
|
||||
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.9.0\n');
|
||||
repo.git('add', '-A');
|
||||
const moduloStamps = push(repo.git('write-tree'));
|
||||
repo.git('checkout', '-q', '-f', 'main');
|
||||
const gated = backstop({ ...fx, record }, moduloStamps, fakeGh({ ...fx, record }, { record, gate: 'success' }).gh);
|
||||
expect(gated.lines.join('\n')).toContain("no green-train record lists main's tree");
|
||||
expect(gated.action).not.toBe('covered');
|
||||
});
|
||||
|
||||
test('a chain that mixes stamped and plain prefixes is not a train head', () => {
|
||||
const fx = trainFixture();
|
||||
const { repo, train, prs } = fx;
|
||||
const s = repo.git('commit-tree', repo.git('rev-parse', `${train[0]}^{tree}`), '-p', train[0]!, '-p', prs[1]!, '-m', `train S2: merge #102 at ${prs[1]!.slice(0, 12)} v1.2.5.0`);
|
||||
expect(trainPrefixes(gitIn(repo.work), s, repo.work, loadPolicyFromHead(repo.work).policy)).toEqual({ error: `${train[0]!.slice(0, 12)} mixes stamped and plain prefixes` });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user