mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-15 12:57:22 +01:00
train S1: merge #3901 at 9041719606 v1.94.0.0
This commit is contained in:
commit
383cfcd494
32 files changed
+1395
-61
No files matched your search
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* lib/ci-carry.ts (GSTA-258 piece 1; design fixes A2, A3): CI's verified run at HEAD's exact, clean tree stands in
|
||||
* for running a lane again. The dependencies are fakes, so nothing here reaches GitHub.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { findCarry, shipLanesErrors, waitForRun } from '../lib/ci-carry';
|
||||
import { validateConfig } from '../lib/pregate/config';
|
||||
import { receiptSummaryLine, validateReceipt } from '../lib/ship-receipt';
|
||||
import type { ApiJob, ApiRun, RunDeps, RunProfile, RunSource, TreeRecord } from '../lib/run-evidence';
|
||||
|
||||
const REPO = 'garrytan/gstack';
|
||||
const HEAD = 'a'.repeat(40);
|
||||
const TREE = 'b'.repeat(40);
|
||||
const NOW = new Date('2026-11-02T12:00:00Z');
|
||||
const PROFILE: RunProfile = { workflow: 'free-tests.yml', events: ['pull_request', 'workflow_dispatch'], jobs: ['free-tests'], artifact: 'tested-tree', os: 'Linux' };
|
||||
|
||||
const run = (id: number, over: Partial<ApiRun> = {}): ApiRun => ({
|
||||
id, run_attempt: 1, status: 'completed', conclusion: 'success', event: 'workflow_dispatch', head_sha: HEAD, path: '.github/workflows/free-tests.yml',
|
||||
created_at: `2026-11-02T1${id % 10}:00:00Z`.replace('T1', 'T0'), updated_at: '2026-11-02T11:30:00Z', repository: { full_name: REPO }, head_repository: { full_name: REPO }, ...over,
|
||||
});
|
||||
const record = (id: number, over: Partial<TreeRecord> = {}): TreeRecord => ({ run_id: id, run_attempt: 1, event: 'workflow_dispatch', sha: HEAD, tree: TREE, files: ['test/a.test.ts', 'test/b.test.ts'], tests: 40, os: 'Linux', ...over });
|
||||
function source(runs: ApiRun[], records: Record<number, TreeRecord> = {}, jobs: ApiJob[] = [{ name: 'free-tests', conclusion: 'success', completed_at: '2026-11-02T11:30:00Z' }]): RunSource {
|
||||
const deps: RunDeps = {
|
||||
run: id => { const r = runs.find(x => x.id === id); if (!r) throw new Error('HTTP 404'); return r; },
|
||||
runsForHead: (_wf, sha) => runs.filter(r => r.head_sha === sha),
|
||||
jobs: () => jobs,
|
||||
artifact: id => { if (!records[id]) throw new Error('no artifact'); return records[id]!; },
|
||||
commitTree: sha => (sha === HEAD ? TREE : null),
|
||||
};
|
||||
return { repo: REPO, deps, profiles: { 'ci-reuse/free-tests': PROFILE }, now: NOW };
|
||||
}
|
||||
const clean = () => TREE;
|
||||
const carry = (src: RunSource, over: Partial<Parameters<typeof findCarry>[1]> = {}) => findCarry(src, { profiles: ['ci-reuse/free-tests'], head: HEAD, tree: TREE, wtree: clean, ...over });
|
||||
const why = (v: ReturnType<typeof findCarry>) => (v.ok ? 'ok' : v.reason);
|
||||
|
||||
describe('findCarry', () => {
|
||||
// Value: protects=a green verified run at this exact tree carries, with a receipt naming it; fails_when=a verified run is ignored or its count is lost;
|
||||
// why_new=GSTA-258 piece 1; seam=lib/run-evidence.ts verifyRun; no_claim=GitHub's API shape (ghRunDeps)
|
||||
test('the newest verified run at HEAD carries, and the receipt names its run and tree', () => {
|
||||
const v = findCarry(source([run(4)], { 4: record(4) }), { profiles: ['ci-reuse/free-tests'], head: HEAD, tree: TREE, wtree: clean, inventory: () => ['test/a.test.ts', 'test/b.test.ts'] });
|
||||
expect(v.ok).toBe(true);
|
||||
if (!v.ok) return;
|
||||
expect(v.tests).toBe(40);
|
||||
expect(v.runs.map(e => e.run_id)).toEqual([4]);
|
||||
expect(v.receipt).toMatch(/^ci-carry tree=bbbbbbbbbbbb tests=40 runs=4\/1 profiles=ci-reuse\/free-tests heads=aaaaaaaaaaaa verified=/);
|
||||
});
|
||||
|
||||
// Value: protects=A2, a carry binds to the working-tree fingerprint before and after; fails_when=a dirty tree or a tree edited during the lookup carries
|
||||
test('a dirty working tree, or one that changes while runs are read, never carries', () => {
|
||||
expect(why(carry(source([run(4)], { 4: record(4) }), { wtree: () => 'c'.repeat(40) }))).toBe('dirty_tree');
|
||||
expect(why(carry(source([run(4)], { 4: record(4) }), { wtree: () => null }))).toBe('dirty_tree');
|
||||
let reads = 0;
|
||||
expect(why(carry(source([run(4)], { 4: record(4) }), { wtree: () => (reads++ ? 'c'.repeat(40) : TREE) }))).toBe('tree_changed');
|
||||
});
|
||||
|
||||
// Value: protects=A3, a lane carries only what CI provably ran; fails_when=a tree.json missing an inventory file still carries
|
||||
test('the inventory must be covered by the runs\' tree.json files', () => {
|
||||
const src = source([run(4)], { 4: record(4) });
|
||||
const v = carry(src, { inventory: () => ['test/a.test.ts', 'test/c.test.ts'] });
|
||||
expect(why(v)).toBe('inventory_uncovered');
|
||||
expect(v.ok ? '' : v.detail).toContain('test/c.test.ts');
|
||||
expect(why(carry(src, { inventory: () => ({ error: 'exited 1' }) }))).toBe('inventory_failed');
|
||||
expect(why(carry(src, { inventory: () => [] }))).toBe('inventory_empty');
|
||||
});
|
||||
|
||||
// Value: protects=fork runs, newer red or running runs, stale runs and empty runs never carry; fails_when=any of them is accepted
|
||||
test('no run, a fork run, a newer red or running run, a stale run and a run with no tests are refused', () => {
|
||||
expect(why(carry(source([])))).toBe('no_run');
|
||||
expect(why(carry(source([run(4, { head_repository: { full_name: 'evil/gstack' } })], { 4: record(4) })))).toBe('no_verified_run');
|
||||
const red = carry(source([run(4), run(6, { conclusion: 'failure' })], { 4: record(4), 6: record(6) }));
|
||||
expect(why(red)).toBe('no_verified_run');
|
||||
expect(red.ok ? '' : red.detail).toContain('run 6: run_not_success; run 4: newer_run_vetoes');
|
||||
expect(why(carry(source([run(4), run(6, { status: 'in_progress', conclusion: null })], { 4: record(4) })))).toBe('no_verified_run');
|
||||
expect(why(carry(source([run(4)], { 4: record(4) }, [{ name: 'free-tests', conclusion: 'success', completed_at: '2026-10-30T00:00:00Z' }])))).toBe('no_verified_run');
|
||||
expect(why(carry(source([run(4)], { 4: record(4, { files: [] }) })))).toBe('no_tests');
|
||||
expect(why(carry(source([run(4)], { 4: record(4) }), { profiles: ['nope'] }))).toBe('no_profile');
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitForRun', () => {
|
||||
const clockAt = () => { let t = 0; return { clock: () => t, sleep: async (ms: number) => { t += ms; } }; };
|
||||
// Value: protects=the bounded wait reports green, red with failing jobs, still running and none; fails_when=it waits forever or calls a red run green
|
||||
test('green, red with its failing jobs, still running at the deadline, and none', async () => {
|
||||
const o = (runs: ApiRun[], jobs?: ApiJob[]) => waitForRun(source(runs, {}, jobs).deps, { workflow: 'free-tests.yml', events: PROFILE.events, head: HEAD, timeoutMs: 60_000, intervalMs: 20_000, ...clockAt() });
|
||||
expect((await o([run(4)])).state).toBe('green');
|
||||
const red = await o([run(4, { conclusion: 'failure' })], [{ name: 'free-tests', conclusion: 'failure' }, { name: 'typecheck', conclusion: 'success' }]);
|
||||
expect(red).toMatchObject({ state: 'red', failing: ['free-tests'] });
|
||||
expect((await o([run(4, { status: 'in_progress', conclusion: null })])).state).toBe('running');
|
||||
expect((await o([])).state).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ship_lanes_from config and the ci_carried receipt field', () => {
|
||||
// Value: protects=A3, the mapping names real profiles and one inventory command; fails_when=a typo or a label-shaped entry validates
|
||||
test('ship_lanes_from validates its profiles and inventory', () => {
|
||||
const profiles = { 'ci-reuse/free-tests': PROFILE };
|
||||
expect(validateConfig({ run_profiles: profiles, ship_lanes_from: { 'bun run test': { profiles: ['ci-reuse/free-tests'], inventory: 'bun run list' } } })).toEqual([]);
|
||||
expect(shipLanesErrors({ 'bun run test': { profiles: ['nope'], inventory: 'x' } }, ['ci-reuse/free-tests']).map(e => e.message)).toEqual(['"nope" is not a run_profiles name']);
|
||||
expect(shipLanesErrors({ 'bun run test': { profiles: ['ci-reuse/free-tests'], inventory: 'a\nb' } }, ['ci-reuse/free-tests'])[0]!.path).toBe('ship_lanes_from["bun run test"].inventory');
|
||||
expect(shipLanesErrors({ 'bun run test': { profiles: ['ci-reuse/free-tests'] , inventory: 'x', label: 'free' } }, ['ci-reuse/free-tests'])[0]!.message).toContain('unknown key');
|
||||
expect(shipLanesErrors(['bun run test'], [])[0]!.path).toBe('ship_lanes_from');
|
||||
});
|
||||
|
||||
// Value: protects=the receipt and its SHIP_RECEIPT line name the carried runs; fails_when=ci_carried is dropped or malformed entries validate
|
||||
test('the receipt records ci_carried and its summary line names each run', () => {
|
||||
const receipt = { schema_version: 1 as const, head: HEAD, base: 'main', ci_carried: [{ lane: 'free', run: 41, attempt: 2, tree: TREE }] };
|
||||
expect(validateReceipt(receipt)).toEqual([]);
|
||||
expect(receiptSummaryLine(receipt)).toContain('ci_carried=free:41/2');
|
||||
expect(validateReceipt({ ...receipt, ci_carried: [{ lane: 'free', run: '41' }] }).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* gstack-evidence reuse and CI-sourced rows (GSTA-258 pieces 1-2, design fixes A4 and A5).
|
||||
*
|
||||
* run --reuse: a lane that passed at this exact working tree, command and execution identity never runs again;
|
||||
* the newest matching row decides (a newer failure vetoes), the reused row keeps its original timestamp and the
|
||||
* max age applies to it. record-ci: a lane CI carried is an explicit CI-sourced row for a run it verified itself, never a fictitious local run.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { findFilesBySuffix, gitIn } from './helpers/scratch-repo';
|
||||
import { runBin } from './helpers/run-bin';
|
||||
|
||||
setDefaultTimeout(60_000);
|
||||
const EVIDENCE = path.join(import.meta.dir, '..', 'bin', 'gstack-evidence');
|
||||
let home: string;
|
||||
let repo: string;
|
||||
let counter: string;
|
||||
|
||||
const run = (args: string[]) => runBin(EVIDENCE, args, { cwd: repo, env: { GSTACK_HOME: home } });
|
||||
const ledger = () => findFilesBySuffix(path.join(home, 'projects'), '-evidence.jsonl')[0]!;
|
||||
const rows = (): any[] => fs.readFileSync(ledger(), 'utf8').trim().split('\n').map(l => JSON.parse(l));
|
||||
const rewrite = (edit: (r: any[]) => any[]) => fs.writeFileSync(ledger(), edit(rows()).map(r => JSON.stringify(r)).join('\n') + '\n');
|
||||
const executions = () => (fs.existsSync(counter) ? fs.readFileSync(counter, 'utf8').length : 0);
|
||||
const lane = (pass: number, fail = 0) => `printf x >> '${counter}'; printf ' ${pass} pass\\n ${fail} fail\\nRan ${pass + fail} tests across 1 file. [1.00ms]\\n' >&2; exit ${fail ? 1 : 0}`;
|
||||
const wtree = () => runBin(path.join(import.meta.dir, '..', 'bin', 'gstack-wtree'), [], { cwd: repo }).stdout.trim();
|
||||
|
||||
beforeEach(() => {
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-reuse-home-'));
|
||||
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-reuse-repo-'));
|
||||
counter = path.join(home, 'executions');
|
||||
gitIn(repo, 'init -q -b main');
|
||||
fs.writeFileSync(path.join(repo, 'src.txt'), 'v1\n');
|
||||
gitIn(repo, 'add src.txt');
|
||||
gitIn(repo, 'commit -q -m init');
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('gstack-evidence run --reuse (A5)', () => {
|
||||
// Value: protects=a lane that passed at this tree never reruns, and the reuse is visible; fails_when=--reuse runs the suite again, or reports a pass it never recorded;
|
||||
// why_new=GSTA-258 piece 2; seam=the ledger and the wtree fingerprint; no_claim=cross-machine reuse (CI carries that)
|
||||
test('a pass at the same tree, command and identity is reused with its original timestamp and count', () => {
|
||||
expect(run(['run', '--label', 'unit', '--', lane(4)]).status).toBe(0);
|
||||
const first = rows().at(-1);
|
||||
const again = run(['run', '--reuse', '--label', 'unit', '--', lane(4)]);
|
||||
expect(again.status).toBe(0);
|
||||
expect(executions()).toBe(1);
|
||||
expect(again.stderr).toContain(`gstack-evidence: reused label=unit tree=${wtree().slice(0, 12)} from=${first.ts}`);
|
||||
expect(again.stderr).toMatch(/gstack-evidence: recorded label=unit exit=0 tests_ran=4 log=\S+/);
|
||||
const copy = rows().at(-1);
|
||||
expect(copy.ts).toBe(first.ts);
|
||||
expect(copy.reused_from).toBe(first.ts);
|
||||
expect(copy.identity).toBe(first.identity);
|
||||
expect(typeof first.identity).toBe('string');
|
||||
expect(run(['check', '--label', 'unit']).stdout).toContain('EVIDENCE: FRESH label=unit exit=0 tests_ran=4');
|
||||
});
|
||||
|
||||
// Value: protects=only the newest matching row decides; fails_when=an older pass is reused under a newer failure on the same tree
|
||||
test('a newer failure on the same tree vetoes an older pass; without --reuse nothing is reused', () => {
|
||||
expect(run(['run', '--label', 'unit', '--', lane(4)]).status).toBe(0);
|
||||
expect(run(['run', '--label', 'unit', '--', lane(3, 1)]).status).toBe(1);
|
||||
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(3, 1)]).status).toBe(1);
|
||||
expect(executions()).toBe(3);
|
||||
expect(run(['run', '--label', 'unit', '--', lane(4)]).status).toBe(0);
|
||||
expect(run(['run', '--label', 'unit', '--', lane(4)]).status).toBe(0);
|
||||
expect(executions()).toBe(5);
|
||||
});
|
||||
|
||||
// Value: protects=reuse binds to the exact command, tree, count and identity; fails_when=a changed command, an edited file, a zero-run or unknown row, or another runtime reuses
|
||||
test('another command, an edited tree, a zero-run or unknown count, or another identity runs again', () => {
|
||||
expect(run(['run', '--label', 'unit', '--', lane(4)]).status).toBe(0);
|
||||
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(5)]).status).toBe(0);
|
||||
expect(executions()).toBe(2);
|
||||
fs.writeFileSync(path.join(repo, 'src.txt'), 'v2\n');
|
||||
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(5)]).status).toBe(0);
|
||||
expect(executions()).toBe(3);
|
||||
rewrite(r => r.map(x => ({ ...x, tests_ran: 0 })));
|
||||
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(5)]).status).toBe(0);
|
||||
expect(executions()).toBe(4);
|
||||
rewrite(r => r.map(x => ({ ...x, tests_ran: 'unknown' })));
|
||||
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(5)]).status).toBe(0);
|
||||
expect(executions()).toBe(5);
|
||||
rewrite(r => r.map(x => ({ ...x, identity: 'f'.repeat(64) })));
|
||||
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(5)]).status).toBe(0);
|
||||
expect(executions()).toBe(6);
|
||||
});
|
||||
|
||||
// Value: protects=the max age applies to the original pass, never to a refreshed copy; fails_when=reuse makes an old pass look new
|
||||
test('a pass older than --max-age is not reused, and a reused copy keeps the age check honest', () => {
|
||||
expect(run(['run', '--label', 'unit', '--', lane(4)]).status).toBe(0);
|
||||
rewrite(r => r.map(x => ({ ...x, ts: new Date(Date.now() - 30 * 3600_000).toISOString() })));
|
||||
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(4)]).status).toBe(0);
|
||||
expect(executions()).toBe(2);
|
||||
rewrite(r => r.map(x => ({ ...x, ts: new Date(Date.now() - 2 * 3600_000).toISOString() })));
|
||||
expect(run(['run', '--reuse', '--max-age', '1', '--label', 'unit', '--', lane(4)]).status).toBe(0);
|
||||
expect(executions()).toBe(3);
|
||||
rewrite(r => r.map(x => ({ ...x, ts: new Date(Date.now() - 3 * 3600_000).toISOString() })));
|
||||
expect(run(['run', '--reuse', '--label', 'unit', '--', lane(4)]).status).toBe(0);
|
||||
expect(executions()).toBe(3);
|
||||
expect(run(['check', '--label', 'unit', '--max-age', '2']).stdout).toContain('EVIDENCE: STALE label=unit');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-evidence record-ci and carried (A4)', () => {
|
||||
const ciRow = () => {
|
||||
const tree = wtree();
|
||||
return { ts: new Date(Date.now() - 600_000).toISOString(), label: 'free', command: 'bun run test', cmd_sha256: createHash('sha256').update('bun run test').digest('hex'), exit: 0, duration_s: 0, commit: gitIn(repo, 'rev-parse HEAD').trim(), tree, dirty: false, wtree: tree, tests_ran: 900, source: 'ci', runs: ['41/2'], profiles: ['ci-reuse/free-tests'] };
|
||||
};
|
||||
|
||||
// Value: protects=a carried lane is an explicit CI row the FRESH check accepts by source, and the receipt lists it only at its own tree;
|
||||
// fails_when=a check cannot tell a CI row from a local run, or a stale carry from an earlier head reaches ci_carried
|
||||
test('a CI-sourced row is FRESH for its exact command, and carried lists it only at its tree', () => {
|
||||
expect(run(['run', '--label', 'unit', '--', lane(2)]).status).toBe(0);
|
||||
fs.appendFileSync(ledger(), JSON.stringify(ciRow()) + '\n');
|
||||
const chk = run(['check', '--label', 'free', '--expect-cmd', 'bun run test', '--max-age', '24']);
|
||||
expect(chk.status).toBe(0);
|
||||
expect(chk.stdout).toContain('EVIDENCE: FRESH label=free exit=0 tests_ran=900');
|
||||
expect(chk.stdout).toContain('source=ci runs=41/2');
|
||||
expect(JSON.parse(run(['carried', '--json']).stdout)).toEqual([{ lane: 'free', run: 41, attempt: 2, tree: wtree() }]);
|
||||
fs.writeFileSync(path.join(repo, 'src.txt'), 'v2\n');
|
||||
gitIn(repo, 'commit -qam two');
|
||||
expect(JSON.parse(run(['carried', '--json']).stdout)).toEqual([]);
|
||||
});
|
||||
|
||||
// Value: protects=record-ci finds and verifies the CI run itself, so no caller supplies run ids, a test count or a tree (/ship pass 1 red-team#1);
|
||||
// fails_when=the forged call from that finding writes a FRESH carried row; seam=no origin, so the base's ship_lanes_from is unreadable
|
||||
test('record-ci takes no run facts from its caller and records nothing without a verified run', () => {
|
||||
const forged = run(['record-ci', '--label', 'free', '--runs', '1/1', '--profiles', 'anything', '--tree', wtree(), '--tests', '999', '--finished', new Date().toISOString(), '--', 'bun run test']);
|
||||
expect(forged.status).toBe(2);
|
||||
expect(forged.stderr).toContain('usage: gstack-evidence record-ci --label <L> --base <base> -- <command>');
|
||||
const unverified = run(['record-ci', '--label', 'free', '--base', 'main', '--', 'bun run test']);
|
||||
expect(unverified.status).toBe(1);
|
||||
expect(unverified.stderr).toMatch(/^CARRY_NONE: config_unavailable: /m);
|
||||
expect(findFilesBySuffix(path.join(home, 'projects'), '-evidence.jsonl')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* A fake gh for the CI-carry tests (GSTA-258): serves the Actions run, jobs, artifact and download reads
|
||||
* lib/run-evidence.ts ghRunDeps makes from a world file, and answers every other gh call with nothing.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export interface FakeWorld {
|
||||
runs: Array<Record<string, unknown> & { id: number; run_attempt: number; head_sha: string; path: string }>;
|
||||
jobs: Record<string, Array<{ name: string; conclusion: string | null; completed_at?: string }>>;
|
||||
trees: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** The stub body for shipFixture's gh(): run this file with the world beside it. */
|
||||
export const fakeGhScript = (worldFile: string) => `exec bun --no-env-file --config=/dev/null '${import.meta.path}' '${worldFile}' "$@"`;
|
||||
|
||||
if (import.meta.main) {
|
||||
const [worldFile, ...a] = process.argv.slice(2);
|
||||
const world: FakeWorld = fs.existsSync(worldFile!) ? JSON.parse(fs.readFileSync(worldFile!, 'utf8')) : { runs: [], jobs: {}, trees: {} };
|
||||
const lines = (list: unknown[]) => process.stdout.write(list.map(x => JSON.stringify(x)).join('\n') + (list.length ? '\n' : ''));
|
||||
if (a[0] === 'api') {
|
||||
const jq = a.indexOf('--jq');
|
||||
const p = a.filter((x, i) => i > 0 && x !== '--paginate' && i !== jq && i !== jq + 1)[0] ?? '';
|
||||
const [route, query = ''] = p.split('?');
|
||||
const q = new URLSearchParams(query);
|
||||
let m: RegExpExecArray | null;
|
||||
if ((m = /\/actions\/runs\/(\d+)$/.exec(route!))) {
|
||||
const run = world.runs.find(r => r.id === Number(m![1]));
|
||||
if (!run) { process.stderr.write('HTTP 404'); process.exit(1); }
|
||||
process.stdout.write(JSON.stringify(run));
|
||||
} else if ((m = /\/actions\/workflows\/([^/]+)\/runs$/.exec(route!))) {
|
||||
lines(world.runs.filter(r => r.head_sha === q.get('head_sha') && r.path === `.github/workflows/${m![1]}`));
|
||||
} else if ((m = /\/actions\/runs\/(\d+)\/attempts\/\d+\/jobs$/.exec(route!))) {
|
||||
lines(world.jobs[m[1]!] ?? []);
|
||||
} else if ((m = /\/actions\/runs\/(\d+)\/artifacts$/.exec(route!))) {
|
||||
lines(world.trees[m[1]!] ? [{ name: q.get('name'), size_in_bytes: 200, expired: false }] : []);
|
||||
} else { process.stderr.write(`fake gh: no route for ${p}`); process.exit(1); }
|
||||
} else if (a[0] === 'run' && a[1] === 'download') {
|
||||
const tree = world.trees[a[2]!];
|
||||
if (!tree) process.exit(1);
|
||||
const dir = a[a.indexOf('--dir') + 1]!;
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'tree.json'), JSON.stringify(tree));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* CI carry and the remote gate (GSTA-258 A): after a verified CI run carries part of the tests stage on a small
|
||||
* machine, the files left over can all be ones the remote gate never runs (gstack's paid evals). The gate itself then
|
||||
* reports tests=0 (PREGATE_REMOTE_PAID_ONLY) and `clear --run` refuses paid_only, so the obligation could never clear.
|
||||
* The carried run clears it instead, through remote_gate_runs and its inventory check. The run API is a fake.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import type { PregateFile } from '../lib/headless-artifacts';
|
||||
import { buildContext } from '../lib/pregate/context';
|
||||
import { clearDecision, runPregate, type RunCheck } from '../lib/pregate/index';
|
||||
import type { ApiRun, RunProfile } from '../lib/run-evidence';
|
||||
import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, type FixtureRepo } from './helpers/pregate-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
|
||||
const LANE = 'pregate.json/remote_gate';
|
||||
const NOW = new Date('2026-11-02T12:00:00Z');
|
||||
const LOCAL_OS = process.platform === 'darwin' ? 'macOS' : 'Linux';
|
||||
const PROFILE: RunProfile = { workflow: 'free-tests.yml', events: ['pull_request', 'workflow_dispatch'], jobs: ['unit'], artifact: 'tested-tree', os: LOCAL_OS };
|
||||
const PAID = 'test/nightly/retry-heavy.test.js';
|
||||
const FREE = ['test/retry.test.js', 'test/util.test.js'];
|
||||
|
||||
function fixture(): FixtureRepo {
|
||||
const repo = makeFixtureRepo({ pregate: { ...BASE_PREGATE, remote_gate: 'node scripts/remote-gate.js' } });
|
||||
repos.push(repo);
|
||||
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n', 'lib/retry.js': 'exports.MAX_RETRIES = 3;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\nexports.touched = true;\n' }, 'touch two subjects');
|
||||
return repo;
|
||||
}
|
||||
|
||||
/** Run 100 of free-tests.yml at HEAD, green, its tree.json listing `files`; the gate's inventory is FREE and its skipped command names PAID. */
|
||||
function runCheck(repo: FixtureRepo, files: string[]): RunCheck {
|
||||
const head = repo.git('rev-parse', 'HEAD');
|
||||
const api: ApiRun = { id: 100, run_attempt: 1, status: 'completed', conclusion: 'success', event: 'workflow_dispatch', head_sha: head, path: '.github/workflows/free-tests.yml', created_at: '2026-11-02T10:00:00Z', updated_at: '2026-11-02T10:30:00Z', repository: { full_name: 'fx/fx' }, head_repository: { full_name: 'fx/fx' } };
|
||||
return {
|
||||
repo: 'fx/fx', profiles: { 'ci-reuse/free-tests': PROFILE }, now: NOW,
|
||||
gateRuns: { profiles: ['ci-reuse/free-tests'], inventory: 'INVENTORY', skipped: 'SKIPPED' },
|
||||
inventory: (command, _tree, selected) => (command === 'SKIPPED' ? (selected ?? []).filter(f => f === PAID) : FREE),
|
||||
deps: {
|
||||
run: () => api, runsForHead: (_wf, sha) => (sha === head ? [api] : []), jobs: () => [{ name: 'unit', conclusion: 'success' }],
|
||||
artifact: () => ({ run_id: 100, run_attempt: 1, event: 'workflow_dispatch', sha: head, head_sha: head, tree: repo.git('rev-parse', 'HEAD^{tree}'), os: [LOCAL_OS], files, tests: files.length * 4 }),
|
||||
commitTree: sha => repo.git('rev-parse', `${sha}^{tree}`),
|
||||
},
|
||||
} as RunCheck;
|
||||
}
|
||||
|
||||
async function carried(repo: FixtureRepo, check: RunCheck): Promise<{ file: PregateFile; progress: string[] }> {
|
||||
const progress: string[] = [];
|
||||
const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: true, env: { ...process.env, ...repo.env }, progress: l => progress.push(l), cores: 4, testBackend: 'local' });
|
||||
if (!('ctx' in loaded)) throw new Error('fixture context refused');
|
||||
loaded.ctx.fromRun = { ref: '100', check };
|
||||
return { file: await runPregate(loaded.ctx, { stage: 'tests' }), progress };
|
||||
}
|
||||
const gate = (file: PregateFile) => file.requires_remote?.find(r => r.lane === LANE);
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('CI carry clears the remote gate on a small machine', () => {
|
||||
// Value: protects=a carried run whose remainder is only gate-skipped (paid) files leaves no obligation nothing can clear;
|
||||
// fails_when=the remainder stays a remote_gate obligation that the gate reports tests=0 for and clear --run refuses paid_only;
|
||||
// why_new=GSTA-258 A, the PREGATE_REMOTE_PAID_ONLY dead end seen at 0fba5533 (112 paid files after run 41554380563); seam=runPregate with a fake run API
|
||||
test('the carried run clears the gate when only skipped files remain, and the receipt counts them', async () => {
|
||||
const repo = fixture();
|
||||
const check = runCheck(repo, FREE);
|
||||
const { file, progress } = await carried(repo, check);
|
||||
const obligation = gate(file)!;
|
||||
expect(obligation.tests).toEqual([PAID]);
|
||||
expect(clearDecision({ ...file, requires_remote: [{ ...obligation, receipt: null }] }, LANE, { runs: ['100'], evidence: check }, repo.work)).toMatchObject({ refused: 'PREGATE_RECEIPT_INVALID', message: expect.stringContaining('reason=paid_only') });
|
||||
expect([obligation.receipt, obligation.cleared_by]).toEqual([expect.stringMatching(new RegExp(`^remote-gate tree=${file.tree.slice(0, 12)} tests=8 skipped=1 runs=100/1 `)), 'ci-carry']);
|
||||
expect(progress).toContain(`CI_CARRY: cleared ${LANE} ${obligation.receipt}`);
|
||||
expect(file.verdict).toBe('requires-remote cleared');
|
||||
}, 120_000);
|
||||
|
||||
// Value: protects=the carry clears the gate only under clear --run's own checks; fails_when=a run that misses an inventory file clears it
|
||||
test('a run that misses a gate inventory file leaves the gate uncleared and says why', async () => {
|
||||
const repo = fixture();
|
||||
const { file, progress } = await carried(repo, runCheck(repo, ['test/util.test.js']));
|
||||
expect(gate(file)?.receipt).toBeNull();
|
||||
expect(progress).toContainEqual(expect.stringMatching(new RegExp(`^CI_CARRY: ${LANE} stays uncleared \\(REUSE: refused reason=inventory_uncovered`)));
|
||||
}, 120_000);
|
||||
|
||||
// Value: protects=when the base declares remote_gate_runs, the uncleared gate names the CI route first and the VM only as an opt-in;
|
||||
// fails_when=the obligation sends an agent to the paid VM while a CI run at the same tree would clear it; why_new=GSTA-258 A, plan choice F1
|
||||
test('with remote_gate_runs on the base, an uncleared gate names the CI route first and the VM as the opt-in', async () => {
|
||||
const repo = makeFixtureRepo({ pregate: { ...BASE_PREGATE, remote_gate: 'node scripts/remote-gate.js', run_profiles: { 'ci-reuse/free-tests': PROFILE }, remote_gate_runs: { profiles: ['ci-reuse/free-tests'], inventory: 'echo test/retry.test.js' } } });
|
||||
repos.push(repo);
|
||||
commit(repo, { 'lib/retry.js': 'exports.MAX_RETRIES = 3;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\nexports.touched = 2;\n' }, 'touch retry');
|
||||
const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: true, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 4, testBackend: 'local' });
|
||||
if (!('ctx' in loaded)) throw new Error('fixture context refused');
|
||||
const file = await runPregate(loaded.ctx, { stage: 'tests' });
|
||||
expect(gate(file)?.receipt).toBeNull();
|
||||
const detail = file.checks.find(c => c.id === 'lanes')?.detail ?? '';
|
||||
expect(detail).toContain(`a verified CI run at this tree clears it: push, then gstack-ci-carry wait --base main and rerun gstack-pregate --allow-repo-commands`);
|
||||
expect(detail.indexOf('gstack-ci-carry wait')).toBeLessThan(detail.indexOf('opt in to the VM: gstack-pregate --remote --spend-cap <usd> --allow-repo-commands'));
|
||||
loaded.ctx.ciCarry = false;
|
||||
const off = (await runPregate(loaded.ctx, { stage: 'tests' })).checks.find(c => c.id === 'lanes')?.detail ?? '';
|
||||
expect(off).not.toContain('gstack-ci-carry wait');
|
||||
expect(off).toContain('run it, then gstack-pregate clear --remote pregate.json/remote_gate');
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* CI-carried lanes end to end (GSTA-258 pieces 1, 2 and 4; design fixes A1-A4): gstack-ship-preflight, gstack-ship-release
|
||||
* finish, push --early and accept, gstack-pregate's automatic --from-run and gstack-ci-carry, against a fixture repository
|
||||
* whose gh is a fake serving one Actions world, so nothing reaches GitHub.
|
||||
*/
|
||||
import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fakeGhScript, type FakeWorld } from './helpers/fake-gh-runs';
|
||||
import { findFilesBySuffix } from './helpers/scratch-repo';
|
||||
import { lineOf, PREFLIGHT, RELEASE, shipFixture, type ShipFx } from './helpers/ship-bin-fixture';
|
||||
|
||||
setDefaultTimeout(120_000);
|
||||
const BIN = path.dirname(RELEASE);
|
||||
const fixtures: ShipFx[] = [];
|
||||
afterEach(() => { while (fixtures.length) fixtures.pop()!.cleanup(); });
|
||||
|
||||
const FREE = "printf x >> ../free-runs; printf ' 3 pass\\n 0 fail\\nRan 3 tests across 1 file. [1.00ms]\\n' >&2";
|
||||
const UNIT = "printf x >> ../unit-runs; printf ' 2 pass\\n 0 fail\\nRan 2 tests across 1 file. [1.00ms]\\n' >&2";
|
||||
const PROFILE = { workflow: 'free.yml', events: ['pull_request', 'workflow_dispatch'], jobs: ['suite'], artifact: 'tested-tree', os: 'Linux' };
|
||||
const BASE = {
|
||||
'.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [FREE]: { profiles: ['ci/free'], inventory: "printf 'test/a.test.ts\\ntest/b.test.ts\\n'" } } }, null, 2) + '\n',
|
||||
'.github/workflows/free.yml': 'name: Free\non: [pull_request, workflow_dispatch]\njobs:\n suite:\n runs-on: ubuntu-latest\n steps:\n - run: bun test\n',
|
||||
};
|
||||
const fx = () => { const f = shipFixture(BASE); fixtures.push(f); return f; };
|
||||
const runs = (f: ShipFx, name: string) => { const p = path.join(f.root, name); return fs.existsSync(p) ? fs.readFileSync(p, 'utf8').length : 0; };
|
||||
const at = (minutesAgo: number) => new Date(Date.now() - minutesAgo * 60_000).toISOString();
|
||||
|
||||
/** One run of free.yml at HEAD (id 7 unless named), green with a tree.json naming HEAD's tree, unless overridden. */
|
||||
function world(f: ShipFx, over: { run?: Record<string, unknown>; tree?: Record<string, unknown>; others?: Array<Record<string, unknown>>; jobs?: Array<{ name: string; conclusion: string | null }> } = {}): void {
|
||||
const head = f.git('rev-parse', 'HEAD').stdout.trim();
|
||||
const tree = f.git('rev-parse', 'HEAD^{tree}').stdout.trim();
|
||||
const run = { id: 7, run_attempt: 1, status: 'completed', conclusion: 'success', event: 'workflow_dispatch', head_sha: head, path: '.github/workflows/free.yml', created_at: at(20), run_started_at: at(20), updated_at: at(5), repository: { full_name: 'fx/fx' }, head_repository: { full_name: 'fx/fx' }, ...over.run };
|
||||
const w: FakeWorld = {
|
||||
runs: [run, ...((over.others ?? []).map(o => ({ ...run, ...o })))] as FakeWorld['runs'],
|
||||
jobs: { 7: (over.jobs ?? [{ name: 'suite', conclusion: 'success' }]).map(j => ({ ...j, completed_at: at(5) })), 9: [{ name: 'suite', conclusion: 'failure', completed_at: at(1) }] },
|
||||
trees: { 7: { run_id: 7, run_attempt: 1, event: 'workflow_dispatch', sha: head, tree, files: ['test/a.test.ts', 'test/b.test.ts'], tests: 12, os: 'Linux', ...over.tree } },
|
||||
};
|
||||
const file = path.join(f.root, 'world.json');
|
||||
fs.writeFileSync(file, JSON.stringify(w));
|
||||
f.gh(fakeGhScript(file));
|
||||
}
|
||||
const preflight = (f: ShipFx, lanes = [`free=${FREE}`, `unit=${UNIT}`]) => f.run(PREFLIGHT, ['--tests-only', '--base', 'main', ...lanes.flatMap(l => ['--lane', l])]);
|
||||
const lane = (out: string, label: string) => out.split('\n').find(l => l.startsWith(`LANE: ${label} `)) ?? '';
|
||||
|
||||
describe('Step 5 lanes: CI carry and the tree cache', () => {
|
||||
// Value: protects=a lane the base maps is carried from CI at this exact tree and never runs; any other lane runs once and is reused at the same tree;
|
||||
// fails_when=the free suite reruns locally beside a green CI run, or a passed lane reruns at an unchanged tree; why_new=GSTA-258 pieces 1-2; seam=fake gh, gstack-evidence ledger
|
||||
test('the mapped lane is carried from the green run; the other lane runs once, then is reused', () => {
|
||||
const f = fx();
|
||||
world(f);
|
||||
const first = preflight(f);
|
||||
expect(first.status).toBe(0);
|
||||
expect(lane(first.stdout, 'free')).toBe('LANE: free carried run=7/1 tests_ran=12');
|
||||
expect(lane(first.stdout, 'unit')).toMatch(/^LANE: unit pass exit=0 tests_ran=2 log=\S+$/);
|
||||
expect([runs(f, 'free-runs'), runs(f, 'unit-runs')]).toEqual([0, 1]);
|
||||
const again = preflight(f);
|
||||
expect(lane(again.stdout, 'unit')).toMatch(/^LANE: unit pass exit=0 tests_ran=2 log=\S+ reused=\S+$/);
|
||||
expect([runs(f, 'free-runs'), runs(f, 'unit-runs')]).toEqual([0, 1]);
|
||||
const chk = f.run(path.join(BIN, 'gstack-evidence'), ['check', '--label', 'free', '--expect-cmd', FREE]);
|
||||
expect(chk.stdout).toContain('source=ci runs=7/1');
|
||||
});
|
||||
|
||||
// Value: protects=A2/A3 and verifyRun's refusals: a dirty tree, an uncovered inventory, a fork run and a newer red run never carry, and a label alone never maps;
|
||||
// fails_when=any of them carries; seam=lib/ci-carry.ts findCarry through runLanes
|
||||
test('a dirty tree, an uncovered inventory, a fork run, a newer red run or a changed command runs the lane here', () => {
|
||||
const f = fx();
|
||||
const cases: Array<[string, () => void, string[]?]> = [
|
||||
['dirty_tree', () => { world(f); f.write({ 'src/b.ts': 'export const b = 3;\n' }); }],
|
||||
['inventory_uncovered', () => { f.git('checkout', '--', 'src/b.ts'); world(f, { tree: { files: ['test/a.test.ts'] } }); }],
|
||||
['fork_run', () => world(f, { run: { head_repository: { full_name: 'evil/fx' } } })],
|
||||
['newer_run_vetoes', () => world(f, { others: [{ id: 9, conclusion: 'failure', created_at: at(2), run_started_at: at(2) }] })],
|
||||
];
|
||||
let expected = 0;
|
||||
for (const [reason, setup] of cases) {
|
||||
setup();
|
||||
const out = preflight(f, [`free=${FREE}`]).stdout;
|
||||
expect([reason, out]).toEqual([reason, expect.stringContaining('CI_CARRY: none label=free (')]);
|
||||
expect(out).toContain(reason);
|
||||
expect(lane(out, 'free')).toMatch(/^LANE: free pass exit=0 tests_ran=3 /);
|
||||
expected += lane(out, 'free').includes('reused=') ? 0 : 1;
|
||||
expect(runs(f, 'free-runs')).toBe(expected);
|
||||
}
|
||||
world(f);
|
||||
const other = preflight(f, [`free=${FREE} `]).stdout;
|
||||
expect(other).not.toContain('CI_CARRY');
|
||||
expect(lane(other, 'free')).not.toContain('carried');
|
||||
});
|
||||
|
||||
// Value: protects=a CI-sourced ledger row exists only for a run gstack-evidence record-ci found and verified itself (/ship pass 1 red-team#1);
|
||||
// fails_when=caller-supplied run ids, test counts or a tree make a FRESH carried row, or an unmapped command or a missing run records one; seam=fake gh
|
||||
test('record-ci verifies the run itself: forged facts, an unmapped command or no run at HEAD record nothing', () => {
|
||||
const f = fx();
|
||||
const ev = (args: string[]) => f.run(path.join(BIN, 'gstack-evidence'), args);
|
||||
const forged = ev(['record-ci', '--label', 'free', '--runs', '1/1', '--profiles', 'anything', '--tree', f.git('rev-parse', 'HEAD^{tree}').stdout.trim(), '--tests', '999', '--finished', at(1), '--', FREE]);
|
||||
expect(forged.status).toBe(2);
|
||||
world(f, { run: { head_sha: '0'.repeat(40) } });
|
||||
const noRun = ev(['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
|
||||
expect([noRun.status, noRun.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: no_run: /m)]);
|
||||
world(f);
|
||||
const unmapped = ev(['record-ci', '--label', 'free', '--base', 'main', '--', 'bun run test']);
|
||||
expect([unmapped.status, unmapped.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: not_mapped: /m)]);
|
||||
expect(ev(['check', '--label', 'free', '--expect-cmd', FREE]).stdout).not.toContain('EVIDENCE: FRESH');
|
||||
expect(JSON.parse(ev(['carried', '--json']).stdout)).toEqual([]);
|
||||
const ok = ev(['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
|
||||
expect([ok.status, ok.stderr]).toEqual([0, expect.stringContaining('tests_ran=12 log=- source=ci runs=7/1 policy=origin/main@')]);
|
||||
const row = JSON.parse(fs.readFileSync(findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl')[0]!, 'utf8').trim().split('\n').at(-1)!);
|
||||
expect(row.policy).toMatch(/^origin\/main@[0-9a-f]{12}:\.gstack\/pregate\.json$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('finish, push --early and accept (A1)', () => {
|
||||
const finishArgs = (f: ShipFx, reviewed: string, post: string) => ['finish', '--base', 'main', '--reviewed-tree', reviewed, '--docs-post', post, '--lane', `free=${FREE}`];
|
||||
const snapshot = (f: ShipFx) => {
|
||||
const out = path.join(f.root, 'post.json');
|
||||
expect(f.run(path.join(BIN, 'gstack-docs-candidate'), ['snapshot', '--out', out, '--audit-id', 'a1', '--mode', 'edit', '--base', 'main', '--docs', 'README.md']).status).toBe(0);
|
||||
return out;
|
||||
};
|
||||
const unattended = { GSTACK_SESSION_KIND: 'unattended' };
|
||||
|
||||
// Value: protects=finish never runs a mapped lane locally on the stamped tree: it defers it to accept and runs only pregate's preflight stage; accept carries
|
||||
// it from the green run at the pushed head and reruns the pregate there; fails_when=the stamped tree reruns the suite, or accept trusts a run at another tree;
|
||||
// why_new=design fix A1; seam=fake gh, gstack-evidence, gstack-pregate
|
||||
test('finish defers the mapped lane; accept carries it from the green run at the pushed head', () => {
|
||||
const f = fx();
|
||||
world(f);
|
||||
const reviewed = f.run(path.join(BIN, 'gstack-wtree'), []).stdout.trim();
|
||||
expect(f.run(RELEASE, ['version', '--base', 'main', '--bump', 'micro']).status).toBe(0);
|
||||
const post = snapshot(f);
|
||||
const pregateFile = path.join(f.work, '.gstack/tmp/pregate.json');
|
||||
const before = fs.existsSync(pregateFile) ? fs.readFileSync(pregateFile, 'utf8') : null;
|
||||
const fin = f.run(RELEASE, finishArgs(f, reviewed, post));
|
||||
expect(fin.stdout).toMatch(/^EVIDENCE: DEFERRED label=free \(/m);
|
||||
expect(lineOf(fin.stdout, 'PREGATE')).toMatch(/^PREGATE: preflight (pass|warn) on the stamped tree/);
|
||||
expect(fs.existsSync(pregateFile) ? fs.readFileSync(pregateFile, 'utf8') : null).toBe(before);
|
||||
expect(fin.stdout).toContain('gstack-ship-release accept --base main --lane');
|
||||
expect(fin.status).toBe(0);
|
||||
expect(runs(f, 'free-runs')).toBe(0);
|
||||
const notPushed = f.run(RELEASE, ['accept', '--base', 'main', '--lane', `free=${FREE}`, '--wait', '0']);
|
||||
expect(notPushed.stdout).toContain('is not origin/feat: run gstack-ship-release push first');
|
||||
const push = f.run(RELEASE, ['push', '--branch', 'feat', '--early', '--base', 'main'], unattended);
|
||||
expect(lineOf(push.stdout, 'SECRETS')).toMatch(/^SECRETS: (pass|warn) \(early push\)$/);
|
||||
expect(lineOf(push.stdout, 'PUSH')).toMatch(/^PUSH: pushed /);
|
||||
world(f, { run: { status: 'in_progress', conclusion: null } });
|
||||
const running = f.run(RELEASE, ['accept', '--base', 'main', '--lane', `free=${FREE}`, '--wait', '0']);
|
||||
expect(running.status).toBe(3);
|
||||
expect(running.stdout).toContain('CI_WAIT: running ci/free run=7/1');
|
||||
world(f, { run: { conclusion: 'failure' }, jobs: [{ name: 'suite', conclusion: 'failure' }] });
|
||||
const red = f.run(RELEASE, ['accept', '--base', 'main', '--lane', `free=${FREE}`, '--wait', '0']);
|
||||
expect(red.stdout).toContain('STOP: free.yml run 7 failed (suite)');
|
||||
world(f);
|
||||
const ok = f.run(RELEASE, ['accept', '--base', 'main', '--lane', `free=${FREE}`, '--wait', '0']);
|
||||
expect(ok.stdout).toContain('CI_WAIT: green ci/free run=7/1');
|
||||
expect(lane(ok.stdout, 'free')).toBe('LANE: free carried run=7/1 tests_ran=12');
|
||||
expect(lineOf(ok.stdout, 'EVIDENCE')).toMatch(/^EVIDENCE: FRESH label=free exit=0 tests_ran=12 .* source=ci runs=7\/1$/);
|
||||
expect(ok.stdout).toContain('CI_CARRY: accepted ci-carry tree=');
|
||||
expect(ok.stdout).toContain('NEXT: Step 18');
|
||||
expect(ok.status).toBe(0);
|
||||
expect(runs(f, 'free-runs')).toBe(0);
|
||||
const carried = JSON.parse(f.run(path.join(BIN, 'gstack-evidence'), ['carried', '--json']).stdout);
|
||||
expect(carried).toEqual([{ lane: 'free', run: 7, attempt: 1, tree: f.git('rev-parse', 'HEAD^{tree}').stdout.trim() }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-pregate tests stage and gstack-ci-carry', () => {
|
||||
// Value: protects=the tests stage carries CI at HEAD's clean tree on its own, and --no-ci-carry or a dirty tree turns it off; fails_when=pregate reruns what CI ran, or carries a dirty tree;
|
||||
// why_new=GSTA-258 piece 1; seam=fake gh; no_claim=a small machine's remote gate obligation (covered by from-run's tests)
|
||||
test('the tests stage accepts the run on its own, and --no-ci-carry or a dirty tree does not', () => {
|
||||
const f = fx();
|
||||
world(f);
|
||||
const pregate = (extra: string[] = []) => f.run(path.join(BIN, 'gstack-pregate'), ['--stage', 'tests', '--allow-repo-commands', '--base', 'main', ...extra]);
|
||||
expect(pregate().stderr).toMatch(/^CI_CARRY: accepted ci-carry tree=[0-9a-f]{12} tests=12 runs=7\/1 profiles=ci\/free /m);
|
||||
expect(pregate(['--no-ci-carry']).stderr).not.toContain('CI_CARRY');
|
||||
f.write({ 'scratch.txt': 'x\n' });
|
||||
expect(pregate().stderr).toContain('CI_CARRY: none (dirty_tree');
|
||||
});
|
||||
|
||||
// Value: protects=the CLI finds and waits on the same evidence; fails_when=find or wait accepts a red run or prints no verdict
|
||||
test('gstack-ci-carry find and wait', () => {
|
||||
const f = fx();
|
||||
world(f);
|
||||
const cli = (args: string[]) => f.run(path.join(BIN, 'gstack-ci-carry'), args);
|
||||
const found = cli(['find', '--base', 'main']);
|
||||
expect([found.status, found.stdout]).toEqual([0, expect.stringMatching(/^CI_CARRY: accepted ci-carry tree=/)]);
|
||||
const mapped = cli(['find', '--base', 'main', '--command', FREE, '--allow-repo-commands']);
|
||||
expect(mapped.status).toBe(0);
|
||||
expect(cli(['find', '--base', 'main', '--command', 'bun run test', '--allow-repo-commands']).stdout).toContain('CI_CARRY: none (not_mapped');
|
||||
expect(cli(['wait', '--base', 'main', '--timeout', '0']).stdout).toContain('CI_WAIT: green ci/free run=7/1');
|
||||
world(f, { run: { conclusion: 'failure' }, jobs: [{ name: 'suite', conclusion: 'failure' }] });
|
||||
const red = cli(['wait', '--base', 'main', '--timeout', '0']);
|
||||
expect([red.status, red.stdout.trim()]).toEqual([1, 'CI_WAIT: red ci/free run=7/1 failing=suite']);
|
||||
expect(cli(['bogus']).status).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"bytes": 1463,
|
||||
"why": "ship: CI-carry, finish/accept split and early push (GSTA-258 piece A)"
|
||||
}
|
||||
Reference in new issue
Block a user