Files
gstack/test/pregate-remote-gate-runs.test.ts
T
Garry Tanandcapy-ai[bot] 56c5aa62ea Let the CI carry accept a run where only an unrelated job was rerun (GST-2284) (#4274)
* Let the CI carry accept a run where only an unrelated job was rerun (GST-2284)

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

* Check artifact ids only when a run holds several same-named artifacts (GST-2284)

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

* Date a carried CI run by its own test jobs so an unrelated rerun cannot refresh old results (GST-2284)

Freshness now reads the newest completed_at among the profile's jobs, not every job in the run, so rerunning a job outside the profile no longer makes days-old test results look fresh. The no-dependencies hint now matches only a missing package or module error.

Review-Finding: p1/red-team#1 reader=red-team
Review-Finding: p1/specialist-batched#1 reader=testing
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Say in the changelog and pregate docs that a carried run is dated by its own test jobs (GST-2284)

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

* Fix the test type error in the new freshness test (GST-2284)

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>
2026-11-10 03:41:44 +00:00

158 lines
12 KiB
TypeScript

/**
* GST-1024: gstack's own .gstack/pregate.json declares remote_gate_runs, so a green Free Tests run at the pregate tree
* clears pregate.json/remote_gate through `gstack-pregate clear --run`. The declaration's commands run against this
* checkout (the inventory must be exactly the free suite CI plans, the skipped list exactly the paid files
* pregate-remote.sh leaves to the paid gate); the clear runs through buildRunCheck in a fixture repository with this
* repository's real run profile and a fake Actions API shaped like a real Free Tests pull_request run (41484634592).
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { bunIsolationArgs } from '../lib/bun-isolation';
import { buildContext } from '../lib/pregate/context';
import { clearDecision, runPregate, type RunCheck } from '../lib/pregate/index';
import { buildRunCheck } from '../lib/pregate/run-check';
import { runInventory, runProfileErrors, type ApiRun, type RemoteGateRuns, type RunProfile } from '../lib/run-evidence';
import { isPaidTestFile } from './helpers/paid-test-set';
import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, type FixtureRepo } from './helpers/pregate-fixture';
const ROOT = path.resolve(import.meta.dir, '..');
const REAL = JSON.parse(fs.readFileSync(path.join(ROOT, '.gstack/pregate.json'), 'utf8')) as { run_profiles: Record<string, RunProfile>; remote_gate_runs?: RemoteGateRuns };
const LANE = 'pregate.json/remote_gate';
const PROFILE = 'ci-reuse/free-tests';
const PAID = 'test/skill-e2e-ship-gate.test.ts';
const dirs: string[] = [];
const repos: FixtureRepo[] = [];
afterEach(() => {
while (repos.length) cleanup(repos.pop()!);
while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true });
});
const tmp = () => { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'gst1024-')); dirs.push(d); return d; };
const lines = (text: string) => text.split('\n').filter(Boolean);
describe.skipIf(process.platform === 'win32')('gstack declares remote_gate_runs (GST-1024)', () => {
test('the declaration names the Free Tests profile; its inventory is exactly the free suite CI plans, and its skipped list is exactly the selected paid files', () => {
const g = REAL.remote_gate_runs;
expect(g?.profiles).toEqual([PROFILE]);
expect(REAL.run_profiles[PROFILE]).toMatchObject({ workflow: 'free-tests.yml', artifact: 'tested-tree', os: 'Linux' });
expect(runProfileErrors(REAL.run_profiles, g)).toEqual([]);
const listed = runInventory(g!.inventory, ROOT);
if ('error' in listed) throw new Error(listed.error);
const planFile = path.join(tmp(), 'plan.json');
const plan = spawnSync('bun', [...bunIsolationArgs(), 'run', 'scripts/test-free-shards.ts', '--ci-plan', planFile, '--shards', '11', '--shard-jobs', '2'], { cwd: ROOT, encoding: 'utf8', timeout: 120_000 });
expect(plan.status).toBe(0);
const planned = (JSON.parse(fs.readFileSync(planFile, 'utf8')) as { shards: Array<{ files: string[] }> }).shards.flatMap(s => s.files);
expect(listed.length).toBeGreaterThan(1000);
expect([...listed].sort()).toEqual([...new Set(planned)].sort());
expect(listed.filter(isPaidTestFile)).toEqual([]);
const selected = path.join(tmp(), 'selected.txt');
fs.writeFileSync(selected, [listed[0], PAID, 'test/codex-e2e-plan-format.test.ts', listed[1]].join('\n') + '\n');
const skipped = runInventory(g!.skipped!, ROOT, { PREGATE_FILES_FILE: selected });
expect(skipped).toEqual([PAID, 'test/codex-e2e-plan-format.test.ts']);
}, 180_000);
});
const INVENTORY = 'printf "test/tool-cli.test.js\\ntest/other.test.js\\n"';
const SKIPPED = 'grep -E "^test/skill-e2e-" "$PREGATE_FILES_FILE" || true';
function fixture(gateRuns: Record<string, unknown>): FixtureRepo {
const repo = makeFixtureRepo({ pregate: { ...BASE_PREGATE, remote_gate: 'node scripts/remote-gate.js', run_profiles: REAL.run_profiles, remote_gate_runs: gateRuns } });
repos.push(repo);
repo.git('checkout', '-q', 'main');
commit(repo, { 'scripts/remote-gate.js': "console.log('PREGATE_REMOTE: tree=' + process.env.PREGATE_TREE + ' tests=3 failed=0');\n" }, 'remote gate');
repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-q', 'feature');
repo.git('merge', '-q', '--ff-only', 'main');
commit(repo, { 'lib/util.js': 'exports.add = (a, b) => a + b + 0;\n' }, 'the real change');
return repo;
}
/** A Free Tests pull_request run as GitHub reports it: the profile's jobs, a tested-tree artifact naming the merge commit. */
interface Run { head: string; files: string[]; conclusion?: string; path?: string; headRepo?: string; mergeTree?: string }
function gateCheck(repo: FixtureRepo, run: Run): RunCheck {
const built = buildRunCheck(repo.work, LANE, true, 'main');
if ('error' in built) throw new Error(built.error);
const profile = REAL.run_profiles[PROFILE]!;
const merge = 'f'.repeat(40);
const finished = new Date(Date.now() - 3600_000).toISOString();
const api: ApiRun = {
id: 41484634592, run_attempt: 1, status: 'completed', conclusion: run.conclusion ?? 'success', event: 'pull_request', head_sha: run.head,
path: run.path ?? `.github/workflows/${profile.workflow}`, created_at: finished, updated_at: finished,
repository: { full_name: 'fx/fx' }, head_repository: { full_name: run.headRepo ?? 'fx/fx' },
};
const tree = (sha: string) => repo.git('rev-parse', `${sha}^{tree}`);
return {
...built,
repo: 'fx/fx',
deps: {
run: id => { if (id !== api.id) throw new Error('HTTP 404'); return api; },
runsForHead: () => [api],
jobs: () => profile.jobs.map(name => ({ name, conclusion: 'success', completed_at: finished })),
artifact: () => ({ run_id: api.id, run_attempt: 1, event: 'pull_request', sha: merge, head_sha: run.head, tree: tree(run.head), workflow: profile.workflow, os: ['Linux', 'macOS', 'Windows'], files: run.files, tests: run.files.length * 20 }),
commitTree: sha => (sha === merge ? run.mergeTree ?? tree(run.head) : tree(sha)),
},
};
}
async function gatedFile(repo: FixtureRepo) {
const loaded = buildContext({ repoRoot: repo.work, base: 'main', policyFrom: 'base', allowRepoCommands: true, env: { ...process.env, ...repo.env }, progress: () => {}, cores: 4, testBackend: 'local' });
if (!('ctx' in loaded)) throw new Error('fixture context refused');
const file = await runPregate(loaded.ctx, { stage: 'tests' });
const gate = file.requires_remote?.find(r => r.lane === LANE);
if (!gate) throw new Error('no remote gate obligation');
gate.tests = [...(gate.tests ?? []), PAID];
repo.git('config', 'remote.origin.url', 'https://github.com/fx/fx.git');
return { file, selected: gate.tests.filter(f => f !== PAID) };
}
describe.skipIf(process.platform === 'win32')('clear --run with the Free Tests profile (GST-1024)', () => {
const RUN = 'https://github.com/fx/fx/actions/runs/41484634592';
const msg = (d: ReturnType<typeof clearDecision>) => ('refused' in d ? `${d.refused}: ${d.message}` : `cleared ${d.receipt}`);
test('a green Free Tests run at the pregate tree clears the gate; the selected paid file the gate skips is counted, not required', async () => {
const repo = fixture({ profiles: [PROFILE], inventory: INVENTORY, skipped: SKIPPED });
const head = repo.git('rev-parse', 'HEAD');
const { file, selected } = await gatedFile(repo);
const files = [...new Set([...selected, 'test/tool-cli.test.js', 'test/other.test.js'])];
const d = clearDecision(file, LANE, { runs: [RUN], evidence: gateCheck(repo, { head, files }) }, repo.work);
expect(msg(d)).toStartWith(`cleared remote-gate tree=${file.tree.slice(0, 12)} tests=${files.length * 20} skipped=1 runs=41484634592/1 profiles=${PROFILE} heads=${head.slice(0, 12)} verified=`);
}, 120_000);
test('refusals: no skipped command, a paid-only selection, the wrong tree, the wrong workflow, a failed or cancelled run, a fork run, a merge behind its base, an uncovered inventory file, a skipped list that names an inventory file', async () => {
const repo = fixture({ profiles: [PROFILE], inventory: INVENTORY, skipped: SKIPPED });
const older = repo.git('rev-parse', 'HEAD~1');
const head = repo.git('rev-parse', 'HEAD');
const { file, selected } = await gatedFile(repo);
const files = [...new Set([...selected, 'test/tool-cli.test.js', 'test/other.test.js'])];
const clear = (run: Run, over: Partial<RunCheck> = {}) => msg(clearDecision(file, LANE, { runs: [RUN], evidence: { ...gateCheck(repo, run), ...over } }, repo.work));
expect(clear({ head, files }, { gateRuns: { profiles: [PROFILE], inventory: INVENTORY } })).toMatch(new RegExp(`reason=inventory_uncovered — 1 of the \\d+ test file\\(s\\) ${LANE} must cover appear in no run's tree.json \\(${PAID}\\)`));
expect(clear({ head: older, files })).toMatch(/^PREGATE_RECEIPT_INVALID: REUSE: refused reason=tree_not_stamp_only — .*lib\/util\.js/);
expect(clear({ head, files, path: '.github/workflows/windows-free-tests.yml' })).toContain('reason=workflow_mismatch run 41484634592 attempt 1 ran .github/workflows/windows-free-tests.yml, not .github/workflows/free-tests.yml');
expect(clear({ head, files, conclusion: 'failure' })).toContain('reason=run_not_success run 41484634592 attempt 1 concluded failure');
expect(clear({ head, files, conclusion: 'cancelled' })).toContain('reason=run_not_success run 41484634592 attempt 1 concluded cancelled');
expect(clear({ head, files, headRepo: 'attacker/fx' })).toContain('reason=fork_run run 41484634592 attempt 1 ran for attacker/fx, not fx/fx');
expect(clear({ head, files, mergeTree: repo.git('rev-parse', `${older}^{tree}`) })).toContain('reason=tree_mismatch');
expect(clear({ head, files: files.filter(f => f !== 'test/other.test.js') })).toContain("reason=inventory_uncovered — 1 of the");
expect(clear({ head, files: files.filter(f => f !== 'test/other.test.js') }, { gateRuns: { profiles: [PROFILE], inventory: INVENTORY, skipped: `${SKIPPED}; echo test/other.test.js` } })).toContain('(test/other.test.js)');
expect(clear({ head, files }, { gateRuns: { profiles: [PROFILE], inventory: INVENTORY, skipped: 'exit 3' } })).toContain('reason=skipped_failed');
expect(clear({ head, files }, { gateRuns: { profiles: [PROFILE], inventory: INVENTORY, skipped: 'exit 3' } })).not.toContain('no installed dependencies');
expect(clear({ head, files }, { gateRuns: { profiles: [PROFILE], inventory: INVENTORY, skipped: "echo 'TypeError: x at /repo/node_modules/zod/index.js:1' >&2; exit 1" } })).not.toContain('no installed dependencies');
expect(clear({ head, files }, { gateRuns: { profiles: [PROFILE], inventory: INVENTORY, skipped: "echo \"error: Cannot find package 'zod' from '/x/test/helpers/paid-test-set.ts'\" >&2; exit 1" } })).toContain('reason=skipped_failed — the remote gate\'s skipped command failed because this checkout has no installed dependencies (install them, for example with bun install, then clear again): ');
const paidOnly = structuredClone(file);
paidOnly.requires_remote!.find(r => r.lane === LANE)!.tests = [PAID];
expect(msg(clearDecision(paidOnly, LANE, { runs: [RUN], evidence: gateCheck(repo, { head, files }) }, repo.work))).toBe(`PREGATE_RECEIPT_INVALID: REUSE: refused reason=paid_only — the remote gate skips every selected test file (1), so no selected test ran; like the gate's own tests=0 report, that never clears; ${LANE} stays uncleared`);
}, 120_000);
test('remote_gate_runs.skipped must be one command line, and an unknown key names it as valid', () => {
const profiles = { p: { workflow: 'w.yml', events: ['push'], jobs: ['j'], artifact: 'a' } };
for (const skipped of [3, '', ' ', 'a\nb', 'x'.repeat(513)]) {
expect(runProfileErrors(profiles, { profiles: ['p'], inventory: 'echo t', skipped }).map(e => e.path)).toEqual(['remote_gate_runs.skipped']);
}
expect(runProfileErrors(profiles, { profiles: ['p'], inventory: 'echo t', skipped: 'echo s' })).toEqual([]);
expect(runProfileErrors(profiles, { profiles: ['p'], inventory: 'echo t', skip: 'echo s' })).toEqual([{ path: 'remote_gate_runs.skip', message: 'unknown key; valid keys: profiles, inventory, skipped' }]);
});
});