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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-03 06:11:39 +00:00
1 parent 513115b889
commit 77f27fa083
8 files changed
+95 -17

No files matched your search

+3
View File
@@ -9,7 +9,10 @@
- An unknown `.gstack/pregate.json` key under a `min_gstack_version` above the running gstack now says `needs gstack >= <version>; upgrade` (`PREGATE_GSTACK_TOO_OLD`). It still refuses.
- docs/pregate.md#prove-a-mapping documents the proof route, the version floors, and that `tree.json` writers using Bun's JUnit must take the root `testsuite` count.
- A test file whose every test skipped on the CI runner no longer counts as carried (GST-1190, harness-evals: 75 tests). `tree.json` now lists such files in `skipped_files`, written by gstack's own Free Tests from the per-file JUnit counts; the pregate tests stage runs them here, and a /ship lane or guard whose inventory names one runs here. A partly skipped file still counts. An older `tree.json` without the list is read as before.
### Tests
- `test/ship-ci-carry.test.ts`: in founderstar's shape (guards only, no lanes), `check-quick` carries from CI, `play-through` reuses a same-tree ledger pass, `assets` runs, and a dirty tree carries nothing. It fails without the guard carry.
- `test/ship-ci-carry.test.ts`: a lane mapped by `jobs` carries when every named job passed at the same tree with no test files, and refuses for a missing job, a skipped job, a rerun whose newest attempt skipped the job, and another tree's run. Those five fail without the change.
- `test/ship-ci-carry.test.ts` (GST-1190): an alias carries and an undeclared env prefix does not; a narrow lane reports its covered file count, not 9677; a jobs-mapped `bunx tsc --noEmit` guard carries with `n/a`; the newer-gstack message; `--simulate` writes and clears nothing and the branch's own map never carries; `carried` drops a row another base mapped. Six of these fail without the change.
- `test/ci-tree-reuse.test.ts` and `test/ship-ci-carry.test.ts` (GST-1190 skipped files): the writer lists only fully skipped files and leaves the list out without complete counts; a fully skipped inventory file stops the lane carry, an empty list or older `tree.json` carries, and the tests stage runs only the skipped selected file. All three fail without the change.
+15 -2
View File
@@ -737,6 +737,17 @@ once:
record (a reuse is never evidence). Its tree equals the tree git computes for
the run's head commit. `files` is a list of paths and `tests` a
non-negative integer when present; without `tests` the file count stands in.
<a id="skipped-files"></a>`skipped_files`, when present, lists the files whose every test skipped on
the runner (a Docker image, a drive path or an installed gstack it lacks;
GST-1190). A carry treats them as uncovered: the pregate tests stage runs
them here, and a /ship lane or guard whose inventory names one runs here
whole. A partly skipped file ran tests and stays covered. gstack's own writer
(`scripts/ci-tree-reuse.ts record`) derives the list from the shards' per-file
JUnit counts and leaves it out when those counts are incomplete. A `tree.json`
without the key (older writers, other repos) is read as before, with every
listed file counted as run; add the list to your writer to close that gap.
The remote gate's clearance does not use it: that gate runs on a Linux runner
like CI, where the same files skip.
- No run of the same workflow on that head, or on pregate.json's own head,
whose newest attempt started at or after the second this run was created
failed, was cancelled or is still running (`run_started_at`, so a red re-run
@@ -895,7 +906,8 @@ whose workflow holds a lane this machine runs (gstack's `ci-reuse/free-tests`
on Linux), with pregate.json's own head as a further veto head. Its tree must be this tree exactly (no
stamp-only substitution). Then:
- The selected local test files the run's `tree.json` lists count as passed.
- The selected local test files the run's `tree.json` lists count as passed,
except those in its `skipped_files` ([above](#skipped-files)), which run here.
The lanes check records them under `from_run` with the run receipt
(`run tree=… tests=… runs=<id>/<attempt> profiles=… heads=… verified=…`) and
prints `REUSE: accepted <n> of <m> selected local test file(s) from <receipt>;
@@ -933,7 +945,8 @@ it accepts a run only when all of these hold:
tree. A `pull_request` run counts only when the pull request already
contained its base.
- Every run executed tests, and with an inventory command, the runs'
`tree.json` files cover every file it prints. A lane mapped by `jobs`
`tree.json` files cover every file it prints, none of them a file whose every
test skipped there (`skipped_files`). A lane mapped by `jobs`
instead needs no test count: each named job succeeded in the run's newest
attempt. Each run is read once more afterward (`run_changed`).
+3 -2
View File
@@ -86,8 +86,9 @@ export function findCarry(src: RunSource, o: { profiles: string[]; head: string;
const listed = o.inventory();
if ('error' in listed) return no('inventory_failed', listed.error);
if (!listed.length) return no('inventory_empty', 'the inventory command printed no files');
const missing = uncoveredFiles(listed, runs);
if (missing.length) return no('inventory_uncovered', `${missing.length} of ${listed.length} inventory file(s) are in no run's tree.json: ${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', …' : ''}`);
const missing = uncoveredFiles(listed, runs, 'uncovered');
const skippedThere = missing.filter(f => runs.some(e => e.skipped?.includes(normalPath(f)))).length;
if (missing.length) return no('inventory_uncovered', `${missing.length} of ${listed.length} inventory file(s) are in no run's tree.json${skippedThere ? ` or skipped every test there (${skippedThere} skipped_files)` : ''}: ${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', …' : ''}`);
covered = new Set(listed.map(normalPath)).size;
}
for (const e of runs) {
+4 -3
View File
@@ -40,7 +40,7 @@ import { selectLanes, type Selection } from '../lane-selection';
import { launchRuntime } from '../runtime';
import { cargoModel, type CargoModel } from '../rust';
import { adapterFor, readRange, STALL_IDLE, supervise, type Counter, type Supervised } from '../supervise';
import { verifyFromRun, type RunProfile } from '../../run-evidence';
import { ranFiles, verifyFromRun, type RunProfile } from '../../run-evidence';
import { matchesAny } from '../../ship-policy';
import type { CheckDef, CheckResult, PregateContext, RemoteObligation } from '../types';
@@ -643,9 +643,10 @@ function fromRun(ctx: PregateContext, sel: Selection): { line: string; passed: s
const names = 'error' in check ? [] : fromRunProfiles(ctx, check.profiles);
const v = 'error' in check ? { ok: false as const, reason: 'profiles_unavailable', detail: check.error } : verifyFromRun(check, ref, names, ctx.wtree, ctx.head);
if (!v.ok) return { line: `REUSE: refused reason=${v.reason} — ${v.detail}; every selected test runs here`, passed: [] };
const ran = new Set(v.evidence.files);
const ran = new Set(ranFiles(v.evidence));
const passed = sel.localTests.filter(t => ran.has(t));
return { line: `REUSE: accepted ${passed.length} of ${sel.localTests.length} selected local test file(s) from ${v.receipt}; ${sel.localTests.length - passed.length} still run`, passed, receipt: v.receipt };
const skipped = sel.localTests.filter(t => v.evidence.skipped?.includes(t)).length;
return { line: `REUSE: accepted ${passed.length} of ${sel.localTests.length} selected local test file(s) from ${v.receipt}; ${sel.localTests.length - passed.length} still run${skipped ? ` (${skipped} skipped every test in the run)` : ''}`, passed, receipt: v.receipt };
}
/**
+15 -5
View File
@@ -50,7 +50,7 @@ export interface ApiRun {
repository?: { full_name: string } | null; head_repository?: { full_name: string } | null;
}
export interface ApiJob { name: string; conclusion: string | null; completed_at?: string | null }
export interface TreeRecord { run_id?: unknown; run_attempt?: unknown; event?: unknown; sha?: unknown; head_sha?: unknown; tree?: unknown; reused?: unknown; reuse?: unknown; os?: unknown; files?: unknown; tests?: unknown }
export interface TreeRecord { run_id?: unknown; run_attempt?: unknown; event?: unknown; sha?: unknown; head_sha?: unknown; tree?: unknown; reused?: unknown; reuse?: unknown; os?: unknown; files?: unknown; tests?: unknown; skipped_files?: unknown }
/** Everything verifyRun reads, injectable so every refusal has a fixture. */
export interface RunDeps {
@@ -64,7 +64,8 @@ export interface RunDeps {
export type RunRefusal = 'run_ref_invalid' | 'other_repository' | 'fork_run' | 'workflow_mismatch' | 'event_mismatch' | 'run_in_progress'
| 'run_not_success' | 'job_missing' | 'job_ambiguous' | 'job_skipped' | 'job_not_success' | 'artifact_missing' | 'artifact_mismatch'
| 'artifact_reused' | 'tree_mismatch' | 'newer_run_vetoes' | 'run_changed' | 'run_stale' | 'os_mismatch' | 'api_error';
export interface RunEvidence { run_id: number; run_attempt: number; head_sha: string; tree: string; profile: string; event: string; finished_at: string; files: string[]; tests: number | null }
/** `skipped`: the files whose every test skipped on the runner (tree.json `skipped_files`, GST-1190); undefined when an older tree.json does not say. */
export interface RunEvidence { run_id: number; run_attempt: number; head_sha: string; tree: string; profile: string; event: string; finished_at: string; files: string[]; tests: number | null; skipped?: string[] }
export type RunVerdict = { ok: true; evidence: RunEvidence } | { ok: false; reason: RunRefusal; detail: string };
const SHA = /^[0-9a-f]{40}$/;
@@ -187,11 +188,13 @@ export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; prof
if (p.os && !oses.includes(p.os)) return fail('os_mismatch', `${at} ran on ${oses.filter(Boolean).map(String).join(', ') || 'an unrecorded OS'}; ${o.profileName} requires ${p.os}`);
if (rec.files !== undefined && (!Array.isArray(rec.files) || rec.files.some(f => typeof f !== 'string'))) return fail('artifact_mismatch', `${at}'s ${p.artifact} files is not a list of paths`);
if (rec.tests !== undefined && (!Number.isInteger(rec.tests) || (rec.tests as number) < 0)) return fail('artifact_mismatch', `${at}'s ${p.artifact} tests is not a non-negative integer`);
if (rec.skipped_files !== undefined && (!Array.isArray(rec.skipped_files) || rec.skipped_files.some(f => typeof f !== 'string'))) return fail('artifact_mismatch', `${at}'s ${p.artifact} skipped_files is not a list of paths`);
const files = ((rec.files ?? []) as string[]).map(normalPath);
const tests = rec.tests === undefined ? null : (rec.tests as number);
const changed = runChanged(deps, id, run.run_attempt);
if (changed) return fail('run_changed', changed);
return { ok: true, evidence: { run_id: id, run_attempt: run.run_attempt, head_sha: run.head_sha, tree: headTree, profile: o.profileName, event: run.event, finished_at: finishedAt!, files, tests } };
const skipped = rec.skipped_files === undefined ? {} : { skipped: (rec.skipped_files as string[]).map(normalPath) };
return { ok: true, evidence: { run_id: id, run_attempt: run.run_attempt, head_sha: run.head_sha, tree: headTree, profile: o.profileName, event: run.event, finished_at: finishedAt!, files, tests, ...skipped } };
} catch (e: any) {
return fail('api_error', `reading run ${id} failed: ${String(e?.message ?? e).slice(0, 300)}`);
}
@@ -211,8 +214,15 @@ export function runInventory(command: string, cwd: string, env?: Record<string,
}
/** The files in `inventory` that no verified run's tree.json lists. */
export function uncoveredFiles(inventory: string[], evidence: RunEvidence[]): string[] {
const ran = new Set(evidence.flatMap(e => e.files.map(normalPath)));
/** A run's files minus the ones whose every test skipped there (GST-1190): only those ran anything. Older tree.json: all files. */
export const ranFiles = (e: RunEvidence): string[] => { const skipped = new Set(e.skipped ?? []); return e.files.map(normalPath).filter(f => !skipped.has(f)); };
/**
* The inventory files no run covers. `skipped: 'uncovered'` (a carry, which drops tests on a machine where they might run)
* also counts a file every test of which skipped in the run; `'covered'` (the remote gate, a Linux runner like CI) does not.
*/
export function uncoveredFiles(inventory: string[], evidence: RunEvidence[], skipped: 'uncovered' | 'covered' = 'covered'): string[] {
const ran = new Set(evidence.flatMap(e => (skipped === 'uncovered' ? ranFiles(e) : e.files.map(normalPath))));
return inventory.filter(f => !ran.has(normalPath(f)));
}
+10 -4
View File
@@ -6,7 +6,8 @@
* The producer. A Free Tests or Windows Free Tests aggregate runs it only after its strict verification
* passed, and uploads the file as the `tested-tree` artifact: run_id, run_attempt, event, sha (GITHUB_SHA),
* head_sha (the pull request head for pull_request, else GITHUB_SHA), tree (git's tree of GITHUB_SHA),
* workflow, os (the OSes the run's test jobs ran on), files (every test file in the plan) and tests.
* workflow, os (the OSes the run's test jobs ran on), files (every test file in the plan), tests and, from complete
* per-file counts, skipped_files (the planned files whose every test skipped on the runner; GST-1190).
*
* bun run scripts/ci-tree-reuse.ts find --workflow <file.yml> --profile <run_profiles name>
* The main-push consumer (the `reuse` job). A push to main whose tree a pull request already tested green
@@ -72,7 +73,8 @@ export function findReuse(deps: ReuseDeps, o: { repo: string; base: string; push
}
/** tree.json for the `tested-tree` artifact; throws on any missing or malformed input, so a broken record fails the aggregate instead of uploading. */
export function treeRecord(env: NodeJS.ProcessEnv, o: { plan: { shards: Array<{ files: string[] }> }; results: Array<{ outcome?: { summary?: { testsRan?: number } } }>; os: string[]; tree: string }) {
type FileCounts = { tests: number; pass: number; fail: number };
export function treeRecord(env: NodeJS.ProcessEnv, o: { plan: { shards: Array<{ files: string[] }> }; results: Array<{ outcome?: { summary?: { testsRan?: number }; counts?: { complete?: boolean; files?: Record<string, FileCounts> } } }>; os: string[]; tree: string }) {
const int = (name: string) => { const v = Number(env[name]); if (!Number.isSafeInteger(v) || v < 1) throw new Error(`${name} is not a positive integer`); return v; };
const sha = env.GITHUB_SHA ?? '';
const event = env.GITHUB_EVENT_NAME ?? '';
@@ -87,7 +89,11 @@ export function treeRecord(env: NodeJS.ProcessEnv, o: { plan: { shards: Array<{
if (!files.length) throw new Error('the plan lists no test files');
const counts = o.results.map(r => r.outcome?.summary?.testsRan);
const tests = o.results.length && counts.every(n => Number.isSafeInteger(n) && n! >= 0) ? counts.reduce((a, b) => a! + b!, 0)! : files.length;
return { run_id: int('GITHUB_RUN_ID'), run_attempt: int('GITHUB_RUN_ATTEMPT'), event, sha, head_sha: head, tree: o.tree, workflow, os: o.os, files, tests };
// skipped_files (GST-1190): planned files whose every test skipped on this runner, so a carry runs them where they might not
// skip. A partly skipped file ran tests and stays covered. Only from complete per-file counts; otherwise left out (unknown).
const counted = o.results.length > 0 && o.results.every(r => r.outcome?.counts?.complete === true && r.outcome.counts.files);
const skipped_files = counted ? files.filter(f => o.results.some(r => { const c = r.outcome!.counts!.files![f]; return !!c && c.tests > 0 && c.pass === 0 && c.fail === 0; })) : undefined;
return { run_id: int('GITHUB_RUN_ID'), run_attempt: int('GITHUB_RUN_ATTEMPT'), event, sha, head_sha: head, tree: o.tree, workflow, os: o.os, files, tests, ...(skipped_files ? { skipped_files } : {}) };
}
function flag(args: string[], name: string): string | undefined { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }
@@ -102,7 +108,7 @@ function record(args: string[]): number {
const out = need(args, '--out');
fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
fs.writeFileSync(out, JSON.stringify(rec, null, 2) + '\n');
console.log(`TESTED_TREE: run ${rec.run_id}/${rec.run_attempt} ${rec.event} head ${rec.head_sha.slice(0, 12)} tree ${rec.tree.slice(0, 12)} files=${rec.files.length} tests=${rec.tests} os=${rec.os.join(',')}`);
console.log(`TESTED_TREE: run ${rec.run_id}/${rec.run_attempt} ${rec.event} head ${rec.head_sha.slice(0, 12)} tree ${rec.tree.slice(0, 12)} files=${rec.files.length} tests=${rec.tests} skipped_files=${rec.skipped_files?.length ?? 'unknown'} os=${rec.os.join(',')}`);
return 0;
}
+13
View File
@@ -129,6 +129,19 @@ describe('treeRecord: the tested-tree producer', () => {
expect(treeRecord(env({ GITHUB_EVENT_NAME: 'pull_request', GITHUB_SHA: MERGE, PR_HEAD_SHA: HEAD }), { plan, results, os: ['Windows'], tree: TREE })).toMatchObject({ event: 'pull_request', sha: MERGE, head_sha: HEAD });
expect(treeRecord(env(), { plan, results: [{ outcome: {} }], os: ['Linux'], tree: TREE }).tests).toBe(3);
});
// Value: protects=a carry knows which files ran nothing on CI (GST-1190, harness-evals: 75 tests skipped on the runner); fails_when=a fully
// skipped file is not listed, a partly skipped one is, or a run without complete counts claims a list
test('skipped_files lists only files whose every test skipped; a partly skipped file stays covered; incomplete counts leave it out', () => {
const n = (tests: number, pass: number, skip: number) => ({ tests, pass, fail: 0, skip, todo: 0, expects: pass });
const counted = [
{ outcome: { summary: { testsRan: 7 }, counts: { complete: true, files: { 'test/a.test.ts': n(3, 0, 3), 'test/b.test.ts': n(4, 2, 2) } } } },
{ outcome: { summary: { testsRan: 5 }, counts: { complete: true, files: { 'test/c.test.ts': n(5, 5, 0) } } } },
];
expect(treeRecord(env(), { plan, results: counted, os: ['Linux'], tree: TREE }).skipped_files).toEqual(['test/a.test.ts']);
expect(treeRecord(env(), { plan, results, os: ['Linux'], tree: TREE })).not.toHaveProperty('skipped_files');
const partial = [counted[0]!, { outcome: { ...counted[1]!.outcome, counts: { complete: false, files: {} } } }];
expect(treeRecord(env(), { plan, results: partial, os: ['Linux'], tree: TREE })).not.toHaveProperty('skipped_files');
});
test('missing or malformed inputs throw, so the aggregate fails instead of uploading a record', () => {
expect(() => treeRecord(env({ GITHUB_EVENT_NAME: 'pull_request', GITHUB_SHA: MERGE }), { plan, results, os: ['Linux'], tree: TREE })).toThrow(/PR_HEAD_SHA/);
expect(() => treeRecord(env({ GITHUB_RUN_ID: '' }), { plan, results, os: ['Linux'], tree: TREE })).toThrow(/GITHUB_RUN_ID/);
+32 -1
View File
@@ -335,7 +335,7 @@ describe('GST-1190: aliases, newer-gstack keys, narrow counts, and proving a map
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'), ['--stage', 'guards', '--allow-repo-commands', '--base', 'main']);
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']]);
@@ -380,3 +380,34 @@ describe('GST-1190: aliases, newer-gstack keys, narrow counts, and proving a map
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_uncovered: 1 of 2 inventory file\(s\) are in no run's tree\.json or skipped every test there \(1 skipped_files\): test\/b\.test\.ts$/m)]);
const out = preflight(f, [`free=${FREE}`]);
expect(out.stdout).toContain('CI_CARRY: none label=free (inventory_uncovered');
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/);
});
});