diff --git a/bin/gstack-evidence b/bin/gstack-evidence index 3b05a94be..e3d2bbab1 100755 --- a/bin/gstack-evidence +++ b/bin/gstack-evidence @@ -708,7 +708,6 @@ async function cmdRecordCi(argv: string[]): Promise { const hereCommand = perFileCommand(m.per_file!, v.remainder); const exit = await cmdRun(["--label", hereLabel, "--", hereCommand]); if (exit !== 0) return none("skipped_failed", `the per_file run of ${v.remainder.length} skipped file(s) exited ${exit} (label=${hereLabel})`); - if (wtree() !== tree) return none("tree_changed", `the working tree changed while the per_file run of label=${hereLabel} ran`); let rows: EvidenceRecord[] = []; try { rows = readJsonl(ledgerPath().file); } catch { rows = []; } here = { label: hereLabel, files: v.remainder, tests_ran: rows.findLast((r) => r.label === hereLabel && r.source === undefined && r.cmd_sha256 === sha256(hereCommand) && r.wtree === tree)?.tests_ran ?? "unknown" }; @@ -717,6 +716,7 @@ async function cmdRecordCi(argv: string[]): Promise { const changed = runChanged(check.deps, e.run_id, e.run_attempt); if (changed) return none("run_changed", changed); } + if (wtree() !== tree) return none("tree_changed", `the working tree changed while the per_file run of label=${hereLabel} ran`); } let paths: ReturnType; try { diff --git a/test/helpers/fake-gh-runs.ts b/test/helpers/fake-gh-runs.ts index 797d06e5c..d7facc95f 100644 --- a/test/helpers/fake-gh-runs.ts +++ b/test/helpers/fake-gh-runs.ts @@ -10,6 +10,8 @@ export interface FakeWorld { /** Keyed `/` for one attempt's jobs, else `` for every attempt. */ jobs: Record>; trees: Record>; + /** 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. */ @@ -28,6 +30,8 @@ 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]}`)); diff --git a/test/ship-ci-carry.test.ts b/test/ship-ci-carry.test.ts index 5e4404d1b..edc3021dc 100644 --- a/test/ship-ci-carry.test.ts +++ b/test/ship-ci-carry.test.ts @@ -535,6 +535,17 @@ describe('GST-1190: a lane carries the covered files and runs only the skipped r 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([]);