v1.94.17.0 ci-carry: guard carry, jobs-mapped lanes, aliases, per-file carry of runner-skipped files, path-skipped jobs (GST-1186, GST-1189, GST-1190) (#3929)

* feat(pregate): a guard that already passed at this tree carries from CI or the ledger (GST-1186)

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

* feat(ci-carry): jobs-mapped lanes, aliases, narrow-lane counts, --simulate proof, policy-base check on carried (GST-1189, GST-1190)

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

* fix(ci-carry): a file whose every test skipped on CI is not carried; tree.json records skipped_files (GST-1190)

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

* feat(ci-carry): path-skipped jobs count only under an unchanged gate; find --policy-from head --simulate (GST-1190)

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

* ship: name the pre-push guards in Step 17 and the early push (moved from #3923 per GSTA-258)

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

* ci-carry: per-file lane carry for runner-skipped files; review fixes (gate inputs, passed ignores CI rows, simulate consent, pull_request-only skips, guard not-owed)

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

* ship: Step 17 names the pre-push guards without a code this branch cannot print

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

* pregate: gstack's bun run test lane runs its runner-skipped files with per_file

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

* ci-carry: a per_file remainder must run tests here and the carried runs must still stand (l6c-p2)

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

* ci-carry: read record-ci from its own last record line; the remainder's count must come from this run's row (l6c-p2 red-team)

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

* test(ci-tree-reuse): type the skipped_files fixture for the test typecheck ratchet

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

* Revert "pregate: gstack's bun run test lane runs its runner-skipped files with per_file"

This reverts commit fafbae5b9b.

* ci-carry: ship the skipped_files reader inert; gstack's writer and per_file move to the adoption PR; Step 17 keeps its refs/heads marker

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

* docs: guard carry, per_file and the too-old message in pregate and troubleshooting (docs audit l6cdocs2)

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

* gstack-evidence record-ci: recheck the working tree after the post-remainder run recheck (#3929 second fix-check)

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

* windows-curation: exclude test/ship-ci-carry.test.ts like its ship-bin-fixture siblings (the path-scoped Windows check runs it at its first spawn)

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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-03 14:14:50 +00:00
1 parent 94771d927f
commit d2e63181d7
35 files changed
+1144 -134

No files matched your search

+4 -2
View File
@@ -40,9 +40,11 @@ describe('findCarry', () => {
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.tests, v.unit]).toEqual([2, 'files']);
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=/);
expect(v.receipt).toMatch(/^ci-carry tree=bbbbbbbbbbbb files=2 runs=4\/1 profiles=ci-reuse\/free-tests heads=aaaaaaaaaaaa verified=/);
const whole = findCarry(source([run(4)], { 4: record(4) }), { profiles: ['ci-reuse/free-tests'], head: HEAD, tree: TREE, wtree: clean });
expect(whole.ok && [whole.tests, whole.unit, whole.receipt.split(' ')[2]]).toEqual([40, undefined, 'tests=40']);
});
// 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
+3 -2
View File
@@ -109,7 +109,7 @@ describe('gstack-evidence run --reuse (A5)', () => {
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'] };
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'], policy: 'origin/main@000000000000:.gstack/pregate.json' };
};
// 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;
@@ -121,7 +121,8 @@ describe('gstack-evidence record-ci and carried (A4)', () => {
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() }]);
expect(JSON.parse(run(['carried', '--base', 'main', '--json']).stdout)).toEqual([{ lane: 'free', run: 41, attempt: 2, tree: wtree() }]);
expect(JSON.parse(run(['carried', '--base', 'release', '--json']).stdout)).toEqual([]);
fs.writeFileSync(path.join(repo, 'src.txt'), 'v2\n');
gitIn(repo, 'commit -qam two');
expect(JSON.parse(run(['carried', '--json']).stdout)).toEqual([]);
+1 -1
View File
@@ -872,7 +872,7 @@ A new PR or an existing draft needs no question: Step 19 creates or keeps the dr
```
It checks the branch, the offer, the gate above (`--gate-open <n>`; `--gate-open-approved` is B)
and `gstack-pregate sealed`, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
`gstack-pregate sealed` and push guards, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
`PUSH: ALREADY_PUSHED` (go to Step 18), else it runs `git push -u origin <branch-name>`.
**If the push fails, STOP.** No Step 19 or publication claim. Report the error:
+1 -1
View File
@@ -871,7 +871,7 @@ A new PR or an existing draft needs no question: Step 19 creates or keeps the dr
```
It checks the branch, the offer, the gate above (`--gate-open <n>`; `--gate-open-approved` is B)
and `gstack-pregate sealed`, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
`gstack-pregate sealed` and push guards, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
`PUSH: ALREADY_PUSHED` (go to Step 18), else it runs `git push -u origin <branch-name>`.
**If the push fails, STOP.** No Step 19 or publication claim. Report the error:
+1 -1
View File
@@ -878,7 +878,7 @@ $GSTACK_ROOT/bin/gstack-ship-release push --branch <branch-name>
```
It checks the branch, the offer, the gate above (`--gate-open <n>`; `--gate-open-approved` is B)
and `gstack-pregate sealed`, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
`gstack-pregate sealed` and push guards, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
`PUSH: ALREADY_PUSHED` (go to Step 18), else it runs `git push -u origin <branch-name>`.
**If the push fails, STOP.** No Step 19 or publication claim. Report the error:
+1 -1
View File
@@ -880,7 +880,7 @@ $GSTACK_ROOT/bin/gstack-ship-release push --branch <branch-name>
```
It checks the branch, the offer, the gate above (`--gate-open <n>`; `--gate-open-approved` is B)
and `gstack-pregate sealed`, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
`gstack-pregate sealed` and push guards, then `git ls-remote --heads origin refs/heads/<branch-name>` against HEAD: a failed lookup STOPs, a match prints
`PUSH: ALREADY_PUSHED` (go to Step 18), else it runs `git push -u origin <branch-name>`.
**If the push fails, STOP.** No Step 19 or publication claim. Report the error:
+7 -2
View File
@@ -7,8 +7,11 @@ import * as path from 'node:path';
export interface FakeWorld {
runs: Array<Record<string, unknown> & { id: number; run_attempt: number; head_sha: string; path: string }>;
/** Keyed `<run id>/<attempt>` for one attempt's jobs, else `<run id>` for every attempt. */
jobs: Record<string, Array<{ name: string; conclusion: string | null; completed_at?: string }>>;
trees: Record<string, Record<string, unknown>>;
/** Once `marker` exists, the next run read writes `content` to `file` (a working-tree edit inside a run recheck). */
editWhenArmed?: { marker: string; file: string; content: string };
}
/** The stub body for shipFixture's gh(): run this file with the world beside it. */
@@ -27,11 +30,13 @@ if (import.meta.main) {
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); }
const e = world.editWhenArmed;
if (e && fs.existsSync(e.marker)) fs.writeFileSync(e.file, e.content);
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+)\/attempts\/(\d+)\/jobs$/.exec(route!))) {
lines(world.jobs[`${m[1]}/${m[2]}`] ?? 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); }
+2 -1
View File
@@ -64,7 +64,8 @@ describe('verifyRun', () => {
['run_stale', world({ run: { ...base.run, updated_at: '2026-10-28T10:00:00Z' } })],
['job_missing', world({ jobs: [{ name: 'lint', conclusion: 'success' }] })],
['job_ambiguous', world({ jobs: [{ name: 'full-suite', conclusion: 'success' }, { name: 'full-suite', conclusion: 'success' }] })],
['job_not_success', world({ jobs: [{ name: 'full-suite', conclusion: 'skipped' }] })],
['job_skipped', world({ jobs: [{ name: 'full-suite', conclusion: 'skipped' }] })],
['job_not_success', world({ jobs: [{ name: 'full-suite', conclusion: 'cancelled' }] })],
['artifact_missing', world({ artifact: new Error('no artifact named full-suite-tree') })],
['artifact_mismatch', world({ artifact: { ...(base.artifact as TreeRecord), run_id: 99 } })],
['artifact_mismatch', world({ artifact: { ...(base.artifact as TreeRecord), run_attempt: 2 } })],
+455 -7
View File
@@ -27,14 +27,15 @@ const runs = (f: ShipFx, name: string) => { const p = path.join(f.root, name); r
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 {
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 }>; attempts?: Record<string, 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 } },
jobs: { 7: (over.jobs ?? [{ name: 'suite', conclusion: 'success' }]).map(j => ({ ...j, completed_at: at(5) })), 9: [{ name: 'suite', conclusion: 'failure', completed_at: at(1) }],
...Object.fromEntries(Object.entries(over.attempts ?? {}).map(([k, js]) => [k, js.map(j => ({ ...j, completed_at: at(5) }))])) },
trees: { 7: { run_id: 7, run_attempt: 1, event: run.event, sha: head, ...(run.event === 'pull_request' ? { head_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));
@@ -51,7 +52,7 @@ describe('Step 5 lanes: CI carry and the tree cache', () => {
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, 'free')).toBe('LANE: free carried run=7/1 tests_ran=2 (files)');
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);
@@ -103,7 +104,7 @@ describe('Step 5 lanes: CI carry and the tree cache', () => {
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@')]);
expect([ok.status, ok.stderr]).toEqual([0, expect.stringContaining('tests_ran=2 unit=files 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$/);
});
@@ -151,8 +152,8 @@ describe('finish, push --early and accept (A1)', () => {
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(lane(ok.stdout, 'free')).toBe('LANE: free carried run=7/1 tests_ran=2 (files)');
expect(lineOf(ok.stdout, 'EVIDENCE')).toMatch(/^EVIDENCE: FRESH label=free exit=0 tests_ran=2 unit=files .* 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);
@@ -192,3 +193,450 @@ describe('gstack-pregate tests stage and gstack-ci-carry', () => {
expect(cli(['bogus']).status).toBe(2);
});
});
describe('GST-1186: pregate guards carry like lanes (founderstar shape: guards only, no lanes)', () => {
// Value: protects=a guard whose exact command the base's ship_lanes_from maps is carried from the verified CI run at this tree, and a guard
// whose exact command already passed at this tree in the ledger is reused, so guard-only repos stop rerunning suites CI or /ship already ran;
// fails_when=check-quick reruns beside a green CI run, play-through reruns after a same-tree ledger pass, or a dirty tree still carries;
// why_new=FOUN-74 (founderstar GST-1184): about 6 minutes of guard suites rerun per /ship; seam=gstack-evidence record-ci and passed; no_claim=CI
const script = (name: string, tests: number) => `printf x >> ../${name}-runs\nprintf ' ${tests} pass\\n 0 fail\\nRan ${tests} tests across 1 file. [1.00ms]\\n' >&2\n`;
const CHECK = 'bash tests/check.sh --quick';
const PLAY = 'bash tests/play-through.sh';
const guard = (id: string, command: string) => ({ id, when: ['src/**'], command, fix: `rerun ${id}` });
const FOUNDERSTAR = {
...BASE,
'.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [CHECK]: { profiles: ['ci/free'], inventory: "printf 'test/a.test.ts\\ntest/b.test.ts\\n'" } },
guards: [guard('check-quick', CHECK), guard('play-through', PLAY), guard('assets', 'bash tests/assets.sh')] }, null, 2) + '\n',
'tests/check.sh': script('check', 4), 'tests/play-through.sh': script('play', 2), 'tests/assets.sh': script('assets', 1),
};
const guards = (f: ShipFx) => f.run(path.join(BIN, 'gstack-pregate'), ['--only', 'guards', '--allow-repo-commands', '--base', 'main']);
const counts = (f: ShipFx) => [runs(f, 'check-runs'), runs(f, 'play-runs'), runs(f, 'assets-runs')];
test('check-quick carries from CI, play-through reuses a same-tree ledger pass, assets runs; a dirty tree carries nothing', () => {
const f = shipFixture(FOUNDERSTAR); fixtures.push(f);
world(f);
const first = guards(f);
expect(first.status, first.stdout + first.stderr).toBe(0);
expect(first.stdout).toContain('GUARD: check-quick carried runs=7/1');
expect(first.stdout).toContain('3 guard(s) passed: check-quick, play-through, assets (1 from an earlier pass at this tree)');
expect(counts(f)).toEqual([0, 1, 1]);
expect(f.run(path.join(BIN, 'gstack-evidence'), ['run', '--label', 'play', '--', PLAY]).status).toBe(0);
const second = guards(f);
expect(second.status).toBe(0);
expect(second.stdout).toMatch(/GUARD: play-through reused label=play source=local ts=\S+/);
expect(counts(f)).toEqual([0, 2, 2]);
f.write({ 'src/b.ts': 'export const b = 3;\n' });
const dirty = guards(f);
expect(dirty.stdout).toMatch(/CI_CARRY: none guard=check-quick \(dirty_tree/);
expect(dirty.stdout).not.toContain('reused');
expect(counts(f)).toEqual([1, 3, 3]);
});
});
describe('GST-1189: a lane carries by named CI jobs instead of an inventory', () => {
// Value: protects=a ship_lanes_from entry that declares { profiles, jobs } carries only when every named job succeeded in the newest attempt
// of the verified run at HEAD's tree, and refuses with a named reason otherwise; fails_when=a typecheck-only lane cannot carry (no test
// files to inventory), or carries beside a missing, skipped or older-attempt job, or another tree's run;
// why_new=gbrain run 41564913688 (tree 13570abc): verify and typecheck passed in CI but no inventory could name their files; seam=fake gh
const VERIFY = 'bun run verify';
const pregateJson = (entry: Record<string, unknown>) => JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [VERIFY]: { profiles: ['ci/free'], ...entry } } }, null, 2) + '\n';
const GBRAIN = { ...BASE, '.gstack/pregate.json': pregateJson({ jobs: ['verify', 'typecheck'] }) };
const ALL = [{ name: 'suite', conclusion: 'success' }, { name: 'verify', conclusion: 'success' }, { name: 'typecheck', conclusion: 'success' }];
const NO_FILES = { files: [], tests: 0 };
const gbrain = () => { const f = shipFixture(GBRAIN); fixtures.push(f); return f; };
const find = (f: ShipFx) => f.run(path.join(BIN, 'gstack-ci-carry'), ['find', '--base', 'main', '--command', VERIFY, '--allow-repo-commands']);
test('every named job green at the same tree carries, with no test files; record-ci records the jobs', () => {
const f = gbrain();
world(f, { jobs: ALL, tree: NO_FILES });
const ok = find(f);
expect([ok.status, ok.stdout]).toEqual([0, expect.stringMatching(/^CI_CARRY: accepted ci-carry tree=[0-9a-f]{12} jobs=verify,typecheck runs=7\/1 profiles=ci\/free /m)]);
const rec = f.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'verify', '--base', 'main', '--', VERIFY]);
expect([rec.status, rec.stderr]).toEqual([0, expect.stringContaining('tests_ran=n/a log=- source=ci runs=7/1')]);
const row = JSON.parse(fs.readFileSync(findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl')[0]!, 'utf8').trim().split('\n').at(-1)!);
expect([row.jobs, row.tests_ran]).toEqual([['verify', 'typecheck'], 'n/a']);
});
test('a missing job refuses as job_missing', () => {
const f = gbrain();
world(f, { jobs: ALL.filter(j => j.name !== 'typecheck'), tree: NO_FILES });
expect(find(f).stdout).toContain('CI_CARRY: none (no_verified_run: ci/free: run 7: job_missing)');
});
test('a skipped job refuses as job_skipped', () => {
const f = gbrain();
world(f, { jobs: ALL.map(j => j.name === 'typecheck' ? { ...j, conclusion: 'skipped' } : j), tree: NO_FILES });
const r = find(f);
expect([r.status, r.stdout]).toEqual([1, expect.stringContaining('CI_CARRY: none (no_verified_run: ci/free: run 7: job_skipped)')]);
});
test('only the newest attempt counts: a rerun that passed carries, a rerun that skipped the job does not', () => {
const f = gbrain();
const failedFirst = ALL.map(j => j.name === 'typecheck' ? { ...j, conclusion: 'failure' } : j);
world(f, { run: { run_attempt: 2 }, tree: { ...NO_FILES, run_attempt: 2 }, attempts: { '7/1': failedFirst, '7/2': ALL } });
expect(find(f).stdout).toMatch(/^CI_CARRY: accepted ci-carry tree=[0-9a-f]{12} jobs=verify,typecheck runs=7\/2 /m);
world(f, { run: { run_attempt: 2 }, tree: { ...NO_FILES, run_attempt: 2 }, attempts: { '7/1': ALL, '7/2': ALL.map(j => j.name === 'typecheck' ? { ...j, conclusion: 'skipped' } : j) } });
expect(find(f).stdout).toContain('CI_CARRY: none (no_verified_run: ci/free: run 7: job_skipped)');
});
test('a run that tested another tree refuses as tree_mismatch', () => {
const f = gbrain();
world(f, { jobs: ALL, tree: { ...NO_FILES, tree: '1'.repeat(40) } });
expect(find(f).stdout).toContain('CI_CARRY: none (no_verified_run: ci/free: run 7: tree_mismatch)');
});
test('an entry declares exactly one of inventory or jobs', () => {
const both = shipFixture({ ...BASE, '.gstack/pregate.json': pregateJson({ jobs: ['verify'], inventory: 'printf x' }) }); fixtures.push(both);
world(both, { jobs: ALL, tree: NO_FILES });
expect(find(both).stdout).toContain('CI_CARRY: none (profiles_unavailable');
const neither = shipFixture({ ...BASE, '.gstack/pregate.json': pregateJson({}) }); fixtures.push(neither);
world(neither, { jobs: ALL, tree: NO_FILES });
expect(find(neither).stdout).toContain('CI_CARRY: none (profiles_unavailable');
});
});
describe('GST-1190: aliases, newer-gstack keys, narrow counts, and proving a mapping without carrying it', () => {
// Value: protects=a lane carries under an exact alias the base declares (never an implicit env-prefix rewrite); a narrow lane never reports
// the whole run's test count; an unknown key under a project floor above this gstack says to upgrade; --simulate proves a head map while
// writing and clearing nothing; carried never lists a row another base's policy mapped;
// fails_when=an env-prefixed lane reruns beside a green run, a 46-test lane records 9677, a proof run's rows land in the PR's own receipt,
// or a branch's own map carries; why_new=HARN-7 (harness-evals #240), ALPH-14 (alphaclaw #310), GEVA-235 (gstack-evals #180), GGST-15; seam=fake gh
const ev = (f: ShipFx, args: string[]) => f.run(path.join(BIN, 'gstack-evidence'), args);
const ledger = (f: ShipFx) => fs.existsSync(path.join(f.state, 'projects')) ? findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl') : [];
const ALL = [{ name: 'suite', conclusion: 'success' }, { name: 'typecheck', conclusion: 'success' }];
test('a declared alias carries like its key; an undeclared env prefix does not', () => {
const TESTS = 'bun test tests/';
const ALIAS = 'env -u ANTHROPIC_API_KEY bun test tests/';
const f = shipFixture({ ...BASE, '.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [TESTS]: { profiles: ['ci/free'], inventory: "printf 'test/a.test.ts\\n'", aliases: [ALIAS] } } }) + '\n' }); fixtures.push(f);
world(f);
const rec = ev(f, ['record-ci', '--label', 'tests', '--base', 'main', '--', ALIAS]);
expect([rec.status, rec.stderr]).toEqual([0, expect.stringContaining('tests_ran=1 unit=files log=- source=ci runs=7/1')]);
const find = (command: string) => f.run(path.join(BIN, 'gstack-ci-carry'), ['find', '--base', 'main', '--command', command, '--allow-repo-commands']).stdout;
expect(find(ALIAS)).toMatch(/^CI_CARRY: accepted ci-carry tree=[0-9a-f]{12} files=1 /m);
expect(find('env -i HOME=/tmp bun test tests/')).toContain('CI_CARRY: none (not_mapped');
});
test('an alias that repeats a key is invalid config', () => {
const f = shipFixture({ ...BASE, '.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { a: { profiles: ['ci/free'], jobs: ['suite'] }, b: { profiles: ['ci/free'], jobs: ['suite'], aliases: ['a'] } } }) + '\n' }); fixtures.push(f);
world(f);
expect(f.run(path.join(BIN, 'gstack-ci-carry'), ['find', '--base', 'main']).stdout).toContain('CI_CARRY: none (profiles_unavailable');
});
test('a carried narrow lane counts its covered inventory files, never the run total (alphaclaw: 46 tests recorded as 9677)', () => {
const f = fx();
world(f, { tree: { files: ['test/a.test.ts', 'test/b.test.ts', 'test/c.test.ts'], tests: 9677 } });
const rec = ev(f, ['record-ci', '--label', 'guards', '--base', 'main', '--', FREE]);
expect([rec.status, rec.stderr]).toEqual([0, expect.stringContaining('tests_ran=2 unit=files log=- source=ci')]);
expect(rec.stderr).not.toContain('9677');
});
test('a jobs-mapped guard carries in the guards stage (harness-evals: bunx tsc --noEmit) and reports n/a', () => {
const TSC = 'bunx tsc --noEmit';
const f = shipFixture({ ...BASE, '.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [TSC]: { profiles: ['ci/free'], jobs: ['typecheck'] } }, guards: [{ id: 'typecheck', when: ['src/**'], command: TSC, fix: 'bunx tsc --noEmit' }] }) + '\n' }); fixtures.push(f);
world(f, { jobs: ALL, tree: { files: [], tests: 0 } });
const g = f.run(path.join(BIN, 'gstack-pregate'), ['--only', 'guards', '--allow-repo-commands', '--base', 'main']);
expect([g.status, g.stdout]).toEqual([0, expect.stringContaining('GUARD: typecheck carried runs=7/1')]);
const row = JSON.parse(fs.readFileSync(ledger(f)[0]!, 'utf8').trim().split('\n').at(-1)!);
expect([row.tests_ran, row.jobs]).toEqual(['n/a', ['typecheck']]);
});
test('an unknown key under a project floor above this gstack says to upgrade; without the floor it stays the generic error', () => {
const conf = JSON.stringify({ future_lanes: {} }) + '\n';
const floor = shipFixture({}, { '.gstack/pregate.json': conf, '.gstack/ship-policy.json': JSON.stringify({ min_gstack_version: '99.0.0.0' }) + '\n' }); fixtures.push(floor);
const newer = floor.run(path.join(BIN, 'gstack-pregate'), ['validate']);
expect(newer.status).not.toBe(0);
expect(newer.stderr).toContain('needs gstack >= 99.0.0.0; upgrade');
expect(newer.stderr).toContain('(PREGATE_GSTACK_TOO_OLD)');
const plain = shipFixture({}, { '.gstack/pregate.json': conf }); fixtures.push(plain);
const typo = plain.run(path.join(BIN, 'gstack-pregate'), ['validate']);
expect([typo.stderr.includes('(PREGATE_CONFIG_INVALID)'), typo.stderr.includes('needs gstack')]).toEqual([true, false]);
});
test('--simulate proves the branch map: every line says SIMULATION, nothing is written, nothing clears, and head policy never carries', () => {
const VERIFY = 'bun run verify';
const head = JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [VERIFY]: { profiles: ['ci/free'], jobs: ['typecheck'] } } }) + '\n';
const f = shipFixture({ '.github/workflows/free.yml': BASE['.github/workflows/free.yml'] }, { '.gstack/pregate.json': head }); fixtures.push(f);
world(f, { jobs: ALL, tree: { files: [], tests: 0 } });
const sim = f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--policy-from', 'head', '--simulate', '--lane', `verify=${VERIFY}`, '--lane', `unit=${UNIT}`]);
const lines = sim.stdout.trim().split('\n');
expect(lines.every(l => l.startsWith('SIMULATION: ')), sim.stdout + sim.stderr).toBe(true);
expect(sim.stdout).toContain('SIMULATION: LANE: verify would carry run=7/1 tests_ran=n/a');
expect(sim.stdout).toContain('SIMULATION: LANE: unit not mapped');
expect([ledger(f), runs(f, 'unit-runs'), fs.existsSync(path.join(f.work, '.gstack', 'tmp', 'pregate.json'))]).toEqual([[], 0, false]);
expect(JSON.parse(ev(f, ['carried', '--json']).stdout)).toEqual([]);
const real = ev(f, ['record-ci', '--label', 'verify', '--base', 'main', '--', VERIFY]);
expect([real.status, real.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: not_mapped: /m)]);
expect(f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--simulate', '--lane', `verify=${VERIFY}`]).status).toBe(2);
});
test('carried lists only rows the receipt base mapped: a proof run against the PR branch never reaches its receipt', () => {
const f = fx();
expect(f.git('push', '-q', 'origin', 'feat').status).toBe(0);
world(f);
expect(ev(f, ['record-ci', '--label', 'free', '--base', 'feat', '--', FREE]).status).toBe(0);
expect(JSON.parse(ev(f, ['carried', '--base', 'main', '--json']).stdout)).toEqual([]);
expect(JSON.parse(ev(f, ['carried', '--base', 'feat', '--json']).stdout)).toEqual([expect.objectContaining({ lane: 'free', run: 7, attempt: 1 })]);
expect(JSON.parse(ev(f, ['carried', '--json']).stdout)).toEqual([]);
});
});
describe('GST-1190: a file whose every test skipped on CI is not carried', () => {
// Value: protects=a lane never carries a test file that ran nothing on the CI runner (Docker images, drive paths, an installed gstack), so
// those tests run here instead of silently dropping; a partly skipped file and an older tree.json without skipped_files carry as before;
// fails_when=harness-evals' 75 runner-skipped tests carry as passed; why_new=HARN-7 (harness-evals #240); seam=fake gh
test('a fully skipped inventory file runs the lane here; a partly skipped one (not listed) and an older tree.json still carry', () => {
const f = fx();
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
const rec = f.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
expect([rec.status, rec.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: inventory_skipped: 1 of 2 inventory file\(s\) skipped every test in CI and the lane declares no per_file runner: test\/b\.test\.ts$/m)]);
const out = preflight(f, [`free=${FREE}`]);
expect(out.stdout).toContain('CI_CARRY: none label=free (inventory_skipped');
expect(runs(f, 'free-runs')).toBe(1);
world(f, { tree: { skipped_files: [] } });
expect(f.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'free', '--base', 'main', '--', FREE]).status).toBe(0);
world(f, { tree: { skipped_files: 'test/b.test.ts' } });
expect(f.run(path.join(BIN, 'gstack-ci-carry'), ['find', '--base', 'main', '--command', FREE, '--allow-repo-commands']).stdout).toContain('artifact_mismatch');
});
test('the tests stage runs a selected file the run skipped entirely, and accepts the rest', () => {
const conf = JSON.stringify({ runner: 'bun test {files}', dependencies: { 'src/**': ['test/a.test.ts', 'test/b.test.ts'] }, run_profiles: { 'ci/free': PROFILE } }) + '\n';
const t = "import { test, expect } from 'bun:test';\ntest('x', () => expect(1).toBe(1));\n";
const f = shipFixture({ ...BASE, '.gstack/pregate.json': conf, 'test/a.test.ts': t, 'test/b.test.ts': t }); fixtures.push(f);
const stage = () => f.run(path.join(BIN, 'gstack-pregate'), ['--stage', 'tests', '--allow-repo-commands', '--base', 'main']);
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
const skipped = stage();
expect(skipped.stdout + skipped.stderr).toMatch(/REUSE: accepted 1 of 2 selected local test file\(s\) from run .*; 1 still run \(1 skipped every test in the run\)/);
world(f);
expect(stage().stdout + stage().stderr).toMatch(/REUSE: accepted 2 of 2 selected local test file\(s\) from run .*; 0 still run/);
});
});
describe('GST-1190: a path-skipped CI job counts only under an unchanged gate', () => {
// Value: protects=a lane whose CI job the workflow skipped by path is not owed only when the gate job that decided the skip succeeded and the
// workflow and the gate's scripts are byte-identical to origin/<base>; otherwise the branch controlled the skip and the lane runs here;
// fails_when=a branch edits the workflow or the gate script to skip its own typecheck and the lane still passes; why_new=TRUT-7 (truther #1169); seam=fake gh
const TSC = 'bunx tsc --noEmit';
const SKIP_PROFILE = { ...PROFILE, jobs: ['suite'], skippable: { jobs: ['typecheck'], gate: 'changes', scripts: ['scripts/changes.sh'] } };
const WORKFLOW = 'name: Free\non: [pull_request, workflow_dispatch]\njobs:\n changes:\n runs-on: ubuntu-latest\n steps:\n - run: bash scripts/changes.sh\n suite:\n runs-on: ubuntu-latest\n steps:\n - run: bun test\n typecheck:\n needs: changes\n runs-on: ubuntu-latest\n steps:\n - run: bunx tsc --noEmit\n';
const SKIP_BASE = {
'.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': SKIP_PROFILE }, ship_lanes_from: { [TSC]: { profiles: ['ci/free'], jobs: ['typecheck'] } } }) + '\n',
'.github/workflows/free.yml': WORKFLOW,
'scripts/changes.sh': 'echo "typecheck=false" >> "$GITHUB_OUTPUT"\n',
};
const JOBS = (gate: string) => [{ name: 'changes', conclusion: gate }, { name: 'suite', conclusion: 'success' }, { name: 'typecheck', conclusion: 'skipped' }];
const setup = (feat: Record<string, string> = { 'src/b.ts': 'export const b = 2;\n' }, gate = 'success', event = 'pull_request') => {
const f = shipFixture(SKIP_BASE, feat); fixtures.push(f);
world(f, { jobs: JOBS(gate), run: { event }, tree: { files: [], tests: 0, event } });
return f;
};
const lane = (f: ShipFx) => preflight(f, [`tsc=${TSC}`]);
test('a skip the unchanged gate decided is not owed', () => {
const f = setup();
const out = lane(f);
expect(out.stdout).toContain('LANE: tsc not owed: CI skipped at this tree (gate job changes, workflow unchanged) run=7/1');
const row = JSON.parse(fs.readFileSync(findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl')[0]!, 'utf8').trim().split('\n').at(-1)!);
expect([row.tests_ran, row.not_owed]).toEqual(['n/a', 'CI skipped at this tree (gate job changes, workflow unchanged)']);
});
test('a branch that changed the workflow decided its own skip: the lane runs here', () => {
const f = setup({ '.github/workflows/free.yml': WORKFLOW + '# skip more\n' });
expect(lane(f).stdout).toContain('CI_CARRY: none label=tsc (no_verified_run: ci/free: run 7: job_skipped)');
});
test('a branch that changed the gate script decided its own skip: the lane runs here', () => {
const f = setup({ 'scripts/changes.sh': 'echo "typecheck=false" >> "$GITHUB_OUTPUT" # always\n' });
expect(lane(f).stdout).toContain('CI_CARRY: none label=tsc (no_verified_run: ci/free: run 7: job_skipped)');
});
test('a gate job that failed decided nothing: the lane runs here', () => {
const f = setup(undefined, 'failure');
expect(lane(f).stdout).toContain('CI_CARRY: none label=tsc (no_verified_run: ci/free: run 7: job_skipped)');
});
test('only a pull_request run\'s skip counts: a push or dispatch gate diffs against the last push, not the PR base', () => {
const f = setup(undefined, 'success', 'workflow_dispatch');
expect(lane(f).stdout).toContain('CI_CARRY: none label=tsc (no_verified_run: ci/free: run 7: job_skipped)');
});
test('gstack-ci-carry find --policy-from head --simulate checks the branch map, says SIMULATION on every line and writes nothing', () => {
const VERIFY = 'bun run verify';
const head = JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [VERIFY]: { profiles: ['ci/free'], jobs: ['suite'] } } }) + '\n';
const f = shipFixture({ '.github/workflows/free.yml': BASE['.github/workflows/free.yml'] }, { '.gstack/pregate.json': head }); fixtures.push(f);
world(f, { tree: { files: [], tests: 0 } });
const cli = (args: string[]) => f.run(path.join(BIN, 'gstack-ci-carry'), args);
const sim = cli(['find', '--base', 'main', '--policy-from', 'head', '--simulate', '--command', VERIFY, '--allow-repo-commands']);
expect(sim.status).toBe(0);
expect(sim.stdout.trim().split('\n').every(l => l.startsWith('SIMULATION: '))).toBe(true);
expect(sim.stdout).toMatch(/^SIMULATION: CI_CARRY: accepted ci-carry tree=[0-9a-f]{12} jobs=suite /m);
expect(fs.existsSync(path.join(f.state, 'projects')) ? findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl') : []).toEqual([]);
expect(cli(['find', '--base', 'main', '--command', VERIFY, '--allow-repo-commands']).stdout).toContain('CI_CARRY: none (not_mapped');
expect(cli(['find', '--base', 'main', '--simulate']).status).toBe(2);
expect(cli(['wait', '--base', 'main', '--policy-from', 'head', '--simulate']).status).toBe(2);
});
});
describe('GST-1190: a lane carries the covered files and runs only the skipped remainder with its per_file runner', () => {
// Value: protects=a fully skipped CI file runs here through the lane's declared per_file runner while the rest carries, a failing remainder
// carries nothing, and a lane with no runner fails closed by name; fails_when=gstack's 6 runner-skipped files make the whole free suite rerun,
// or a remainder failure still records a carried pass; why_new=HARN-7 decision (GSTA-258, per-file lane carry); seam=fake gh, gstack-evidence record-ci
const PER = (exit: number) => `printf '%s\\n' {files} >> ../perfile-args; printf ' 1 pass\\n 0 fail\\nRan 1 tests across 1 file. [1.00ms]\\n' >&2; exit ${exit}`;
const withRunner = (exit: number) => ({ ...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'", per_file: PER(exit) } } }, null, 2) + '\n' });
const args = (f: ShipFx) => { const p = path.join(f.root, 'perfile-args'); return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : ''; };
const rows = (f: ShipFx) => findFilesBySuffix(path.join(f.state, 'projects'), '-evidence.jsonl').flatMap(p => fs.readFileSync(p, 'utf8').trim().split('\n').map(l => JSON.parse(l)));
test('the covered file carries; only the skipped file runs here, and both rows are recorded', () => {
const f = shipFixture(withRunner(0)); fixtures.push(f);
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
const out = preflight(f, [`free=${FREE}`]);
expect(out.status, out.stdout + out.stderr).toBe(0);
expect(lane(out.stdout, 'free')).toBe('LANE: free carried run=7/1 tests_ran=1 (files) skipped_here=1 file(s) tests_ran_here=1');
expect([args(f), runs(f, 'free-runs')]).toEqual(['test/b.test.ts\n', 0]);
const all = rows(f);
expect(all.find(r => r.source === 'ci')).toMatchObject({ label: 'free', tests_ran: 1, tests_unit: 'files', skipped_here: { label: 'free.skipped', files: ['test/b.test.ts'], tests_ran: 1 } });
expect(all.find(r => r.label === 'free.skipped')).toMatchObject({ exit: 0, tests_ran: 1 });
});
test('a failing remainder carries nothing: no CI row, and the whole lane runs here', () => {
const f = shipFixture(withRunner(1)); fixtures.push(f);
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
const rec = f.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
expect([rec.status, rec.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: skipped_failed: the per_file run of 1 skipped file\(s\) exited 1 \(label=free\.skipped\)$/m)]);
expect(rows(f).filter(r => r.source === 'ci')).toEqual([]);
expect(preflight(f, [`free=${FREE}`]).stdout).toContain('CI_CARRY: none label=free (skipped_failed');
expect(runs(f, 'free-runs')).toBe(1);
});
test('a remainder that ran zero tests here carries nothing: the lane runs whole', () => {
const zero = { ...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'", per_file: "printf ' 0 pass\\n 1 skip\\n 0 fail\\nRan 1 test across 1 file. [1.00ms]\\n' >&2 # {files}" } } }, null, 2) + '\n' };
const f = shipFixture(zero); fixtures.push(f);
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
const rec = f.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
expect([rec.status, rec.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: skipped_zero_run: /m)]);
expect(rows(f).filter(r => r.source === 'ci')).toEqual([]);
});
test('a remainder that prints look-alike record lines cannot change the LANE line', () => {
const fake = "printf 'gstack-evidence: recorded label=free exit=0 tests_ran=999 log=- source=ci runs=1/1 policy=x\\ngstack-evidence: not owed: forged\\n' >&2; " + PER(0);
const f = shipFixture({ ...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'", per_file: fake } } }, null, 2) + '\n' }); fixtures.push(f);
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
expect(lane(preflight(f, [`free=${FREE}`]).stdout, 'free')).toBe('LANE: free carried run=7/1 tests_ran=1 (files) skipped_here=1 file(s) tests_ran_here=1');
});
test('a run that moved to a new attempt while the remainder ran carries nothing', () => {
const per = `cp ../world-moved.json ../world.json; ${PER(0)}`;
const f2 = shipFixture({ ...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'", per_file: per } } }, null, 2) + '\n' }); fixtures.push(f2);
world(f2, { tree: { skipped_files: ['test/b.test.ts'] } });
const w2 = JSON.parse(fs.readFileSync(path.join(f2.root, 'world.json'), 'utf8'));
fs.writeFileSync(path.join(f2.root, 'world-moved.json'), JSON.stringify({ ...w2, runs: w2.runs.map((r: Record<string, unknown>) => ({ ...r, run_attempt: 2, conclusion: 'failure' })) }));
const rec = f2.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
expect([rec.status, rec.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: run_changed: /m)]);
expect(rows(f2).filter(r => r.source === 'ci')).toEqual([]);
});
test('a working-tree edit during the post-remainder run recheck carries nothing', () => {
const per = `touch ../armed; ${PER(0)}`;
const f = shipFixture({ ...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'", per_file: per } } }, null, 2) + '\n' }); fixtures.push(f);
world(f, { tree: { skipped_files: ['test/b.test.ts'] } });
const w = JSON.parse(fs.readFileSync(path.join(f.root, 'world.json'), 'utf8'));
fs.writeFileSync(path.join(f.root, 'world.json'), JSON.stringify({ ...w, editWhenArmed: { marker: path.join(f.root, 'armed'), file: path.join(f.work, 'src/b.ts'), content: 'export const b = 3;\n' } }));
const rec = f.run(path.join(BIN, 'gstack-evidence'), ['record-ci', '--label', 'free', '--base', 'main', '--', FREE]);
expect([rec.status, rec.stderr]).toEqual([1, expect.stringMatching(/^CARRY_NONE: tree_changed: /m)]);
expect(rows(f).filter(r => r.source === 'ci')).toEqual([]);
});
test('per_file is validated: it needs {files} and an inventory entry', async () => {
const { shipLanesErrors } = await import('../lib/ci-carry');
expect(shipLanesErrors({ x: { profiles: ['p'], inventory: 'ls', per_file: 'bun test {files}' } }, ['p'])).toEqual([]);
expect(shipLanesErrors({ x: { profiles: ['p'], inventory: 'ls', per_file: 'bun test' } }, ['p'])).toEqual([expect.objectContaining({ path: 'ship_lanes_from["x"].per_file' })]);
expect(shipLanesErrors({ x: { profiles: ['p'], jobs: ['j'], per_file: 'bun test {files}' } }, ['p'])).toEqual([expect.objectContaining({ path: 'ship_lanes_from["x"].per_file' })]);
});
});
describe('GSTA-258 L6 review fixes: gate inputs, ledger CI rows, simulate consent', () => {
// Value: protects=a branch cannot move a path skip through a gate file gstack failed to compare, a stale CI ledger row never passes a guard
// after record-ci refused, and --simulate runs a branch's inventory only with --allow-repo-commands; fails_when=the l6c-p1 reproductions pass
// again; why_new=l6c-p1 findings (readers 15 and 16); seam=fake gh, gstack-evidence
const TSC = 'bunx tsc --noEmit';
const flow = (gateJob: string, before = '') => `name: Free\non: [pull_request, workflow_dispatch]\njobs:\n${before} changes:\n runs-on: ubuntu-latest\n${gateJob} suite:\n runs-on: ubuntu-latest\n steps:\n - run: bun test\n typecheck:\n needs: changes\n runs-on: ubuntu-latest\n steps:\n - run: bunx tsc --noEmit\n`;
const profile = (scripts: string[] | undefined) => ({ ...PROFILE, jobs: ['suite'], skippable: { jobs: ['typecheck'], gate: 'changes', ...(scripts ? { scripts } : {}) } });
const base = (workflow: string, extra: Record<string, string>, scripts: string[] | undefined = ['scripts/changes.sh']) => ({
'.gstack/pregate.json': JSON.stringify({ run_profiles: { 'ci/free': profile(scripts) }, ship_lanes_from: { [TSC]: { profiles: ['ci/free'], jobs: ['typecheck'] } } }) + '\n',
'.github/workflows/free.yml': workflow, 'scripts/changes.sh': 'echo typecheck=false >> "$GITHUB_OUTPUT"\n', ...extra,
});
const JOBS = [{ name: 'changes', conclusion: 'success' }, { name: 'suite', conclusion: 'success' }, { name: 'typecheck', conclusion: 'skipped' }];
const tsc = (b: Record<string, string>, feat: Record<string, string>) => { const f = shipFixture(b, feat); fixtures.push(f); world(f, { jobs: JOBS, run: { event: 'pull_request' }, tree: { files: [], tests: 0, event: 'pull_request' } }); return preflight(f, [`tsc=${TSC}`]).stdout; };
const REFUSED = 'CI_CARRY: none label=tsc (no_verified_run: ci/free: run 7: job_skipped)';
test('a gate script reached through ${{ github.workspace }} is compared', () => {
const b = base(flow(' steps:\n - run: bash scripts/changes.sh && bash ${{ github.workspace }}/scripts/gate.sh\n'), { 'scripts/gate.sh': 'exit 0\n' });
expect(tsc(b, { 'scripts/gate.sh': 'exit 0 # moved\n' })).toContain(REFUSED);
});
test('a gate script under the job\'s working-directory is compared', () => {
const b = base(flow(' defaults:\n run:\n working-directory: tools\n steps:\n - run: bash ../scripts/changes.sh && bash gate2.sh\n'), { 'tools/gate2.sh': 'exit 0\n' });
expect(tsc(b, { 'tools/gate2.sh': 'exit 0 # moved\n' })).toContain(REFUSED);
});
test('a gate that runs a package script compares package.json', () => {
const b = base(flow(' steps:\n - run: bash scripts/changes.sh && bun run detect\n'), { 'package.json': '{"scripts":{"detect":"true"}}\n' });
expect(tsc(b, { 'package.json': '{"scripts":{"detect":"echo typecheck=false"}}\n' })).toContain(REFUSED);
});
test('the gate is the job, not a same-named step in an earlier job', () => {
const b = base(flow(' steps:\n - run: bash scripts/changes.sh && bash scripts/extra.sh\n', ' lint:\n runs-on: ubuntu-latest\n steps:\n - run: echo lint\n name: changes\n'), { 'scripts/extra.sh': 'exit 0\n' });
expect(tsc(b, { 'scripts/extra.sh': 'exit 0 # moved\n' })).toContain(REFUSED);
});
test('a guard whose CI job the unchanged gate skipped says not owed, never carried', () => {
const b = base(flow(' steps:\n - run: bash scripts/changes.sh\n'), {});
const conf = JSON.parse(b['.gstack/pregate.json']);
b['.gstack/pregate.json'] = JSON.stringify({ ...conf, guards: [{ id: 'tsc', when: ['src/**'], command: TSC }] }) + '\n';
const f = shipFixture(b, { 'src/b.ts': 'export const b = 2;\n' }); fixtures.push(f);
world(f, { jobs: JOBS, run: { event: 'pull_request' }, tree: { files: [], tests: 0, event: 'pull_request' } });
const out = f.run(path.join(BIN, 'gstack-pregate'), ['--only', 'guards', '--allow-repo-commands', '--base', 'main']);
expect(out.stdout, out.stdout + out.stderr).toContain('GUARD: tsc not owed: CI skipped at this tree (gate job changes, workflow unchanged) runs=7/1');
});
test('a skippable profile must declare its gate scripts', async () => {
const { runProfileErrors } = await import('../lib/run-evidence');
expect(runProfileErrors({ p: profile(undefined) }, undefined)).toEqual([expect.objectContaining({ path: 'run_profiles["p"].skippable' })]);
expect(runProfileErrors({ p: profile(['scripts/changes.sh']) }, undefined)).toEqual([]);
});
test('passed never counts a CI row: a carry under another base or after a refusal does not pass a guard', () => {
const f = fx();
expect(f.git('push', '-q', 'origin', 'feat').status).toBe(0);
world(f);
const ev = (a: string[]) => f.run(path.join(BIN, 'gstack-evidence'), a);
expect(ev(['record-ci', '--label', 'free', '--base', 'feat', '--', FREE]).status).toBe(0);
const p = ev(['passed', '--', FREE]);
expect([p.status, p.stdout]).toEqual([1, expect.stringMatching(/^NOT_PASSED: /m)]);
});
test('passed: a newer failure of the same command at this tree vetoes the older pass', () => {
const f = fx();
const CMD = "test -e ../fail-flag && exit 1; printf ' 1 pass\\n 0 fail\\nRan 1 tests across 1 file. [1.00ms]\\n' >&2";
const ev = (a: string[]) => f.run(path.join(BIN, 'gstack-evidence'), a);
expect(ev(['run', '--label', 'g', '--', CMD]).status).toBe(0);
expect(ev(['passed', '--', CMD]).stdout).toMatch(/^PASSED: label=g source=local /m);
fs.writeFileSync(path.join(f.root, 'fail-flag'), '');
expect(ev(['run', '--label', 'g', '--', CMD]).status).toBe(1);
expect(ev(['passed', '--', CMD]).stdout).toMatch(/^NOT_PASSED: /m);
});
test('--simulate runs a branch inventory only with --allow-repo-commands', () => {
const INV = "printf x >> ../inv-runs; printf 'test/a.test.ts\\ntest/b.test.ts\\n'";
const head = JSON.stringify({ run_profiles: { 'ci/free': PROFILE }, ship_lanes_from: { [FREE]: { profiles: ['ci/free'], inventory: INV } } }) + '\n';
const f = shipFixture({ '.github/workflows/free.yml': BASE['.github/workflows/free.yml'] }, { '.gstack/pregate.json': head }); fixtures.push(f);
world(f);
const sim = (extra: string[]) => f.run(PREFLIGHT, ['--tests-only', '--base', 'main', '--policy-from', 'head', '--simulate', '--lane', `free=${FREE}`, ...extra]);
const no = sim([]);
expect([no.status, runs(f, 'inv-runs')]).toEqual([1, 0]);
expect(no.stdout).toContain('pass --allow-repo-commands');
const yes = sim(['--allow-repo-commands']);
expect([yes.status, runs(f, 'inv-runs')], yes.stdout).toEqual([0, 1]);
expect(yes.stdout).toContain('SIMULATION: LANE: free would carry run=7/1 tests_ran=2 unit=files');
});
});
@@ -0,0 +1,4 @@
{
"bytes": 14,
"why": "GST-1138: ship names the push guards in Step 17 and the early push"
}