mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-11 19:20:26 +01:00
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:
1 parent
94771d927f
commit
d2e63181d7
35 files changed
+1144
-134
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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); }
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
Reference in new issue
Block a user