mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 15:27:56 +01:00
* 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>
110 lines
9.2 KiB
TypeScript
110 lines
9.2 KiB
TypeScript
/**
|
|
* pregate check `guards` (tier 2, preflight; field wave CD1): the repo's own
|
|
* mechanical gates a lane must pass before it reports done. Each
|
|
* `.gstack/pregate.json` guard `{ id, when, command, fix?, timeout_s? }`
|
|
* whose `when` globs match a touched file runs in its own process group
|
|
* (lib/pregate/supervise.ts: bash, under the verified launch Bun, output to a
|
|
* log file, with its own timeout and the stall timeout). A failing guard
|
|
* fails pregate naming the guard and its fix; a timed-out one is
|
|
* `incomplete`; without --allow-repo-commands the selected guards are listed
|
|
* and the check is `incomplete`. Guards are never downgradable. Field
|
|
* incident: a gbrain serial test landed without its manifest row and
|
|
* passed both the isolation check and pregate; `test/cli-goldens.test.ts`
|
|
* spawns the CLI as a subprocess, so no import graph selects it.
|
|
*
|
|
* GST-1186: a guard whose exact command already passed at this tree is not run
|
|
* again. #3901's carry (gstack-evidence record-ci) carries it from a verified
|
|
* CI run when the base's ship_lanes_from maps the command, else a counted
|
|
* ledger pass of the same command at the same working tree stands
|
|
* (gstack-evidence passed). ship_lanes_from is read from origin/<base> only, so
|
|
* the PR that adds a mapping cannot carry from it yet.
|
|
*/
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { laneMapping, recordCiResult } from '../../ci-carry';
|
|
import { matchesAny } from '../../ship-policy';
|
|
import type { Guard } from '../config';
|
|
import { baseInputs, sha256 } from '../context';
|
|
import { classifyCommandEnvironment, classifyEnvironment, environmentLine, environmentResult, launchCause } from '../environment';
|
|
import { launchRuntime } from '../runtime';
|
|
import { checkScratch } from '../run-scratch';
|
|
import { readRange, STALL_IDLE, supervise } from '../supervise';
|
|
import type { EnvironmentCause } from '../types';
|
|
import type { CheckDef, CheckResult, PregateContext } from '../types';
|
|
import { runScriptSync } from '../../script-invocation';
|
|
|
|
interface GuardRun { guard: Guard; exit: number | null; timedOut: boolean; stalled: boolean; tail: string[]; log: string; environment: EnvironmentCause | null; prior?: string; carryNone?: string }
|
|
|
|
const EVIDENCE = path.resolve(import.meta.dir, '..', '..', '..', 'bin', 'gstack-evidence');
|
|
|
|
/**
|
|
* The pass that already covers this guard's exact command at this tree (GST-1186): a CI carry (or a CI skip the unchanged gate
|
|
* decided, printed as not owed), else a same-tree local ledger pass under the runtime the guard would launch with.
|
|
*/
|
|
function priorPass(ctx: PregateContext, guard: Guard, env: NodeJS.ProcessEnv): { prior?: string; carryNone?: string } {
|
|
const evidence = (args: string[]) => runScriptSync(EVIDENCE, args, { cwd: ctx.repoRoot, env, encoding: 'utf8', timeout: 600_000 });
|
|
let carryNone: string | undefined;
|
|
if (laneMapping(ctx.config.ship_lanes_from, guard.command)) {
|
|
const ci = evidence(['record-ci', '--label', `guard-${guard.id}`, '--base', ctx.base, '--', guard.command]);
|
|
const got = recordCiResult(String(ci.stderr ?? ''));
|
|
if (ci.status === 0 && got.runs) return { prior: got.notOwed ? `not owed: ${got.notOwed} runs=${got.runs}` : `carried runs=${got.runs}` };
|
|
carryNone = got.none ?? `record-ci exit ${ci.status}`;
|
|
}
|
|
const local = evidence(['passed', '--', guard.command]);
|
|
const hit = /^PASSED: (.+)$/m.exec(String(local.stdout ?? ''))?.[1];
|
|
return local.status === 0 && hit ? { prior: `reused ${hit}`, carryNone } : { carryNone };
|
|
}
|
|
|
|
async function runGuard(ctx: PregateContext, guard: Guard, env: NodeJS.ProcessEnv, dir: string): Promise<GuardRun> {
|
|
const timeoutMs = guard.timeout_s ? guard.timeout_s * 1000 : ctx.timeoutMs;
|
|
const log = path.join(dir, `${guard.id}.log`);
|
|
fs.writeFileSync(log, '');
|
|
const r = await supervise({ command: guard.command, cwd: ctx.repoRoot, env: { ...env, CI: env.CI ?? '1' }, log, deadline: Date.now() + timeoutMs, stallMs: ctx.stallTimeoutMs, label: `guard ${guard.id}`, progress: ctx.progress });
|
|
const output = readRange(log, r.start, r.end).trim();
|
|
// A guard that could not start, or whose own binary is off PATH (exit 127), is the machine's failure; a guard that ran and said no is the diff's,
|
|
// whatever its output mentions (a test about rate limits must not read as a quota refusal, and a `bun run lint` whose script names a missing tool is the diff's).
|
|
const environment = r.outcome === 'spawn-failed' ? { ...(classifyEnvironment(r.error ?? '', null) ?? launchCause('missing-binary', r.error ?? 'the guard could not be started')), proof: 'launch' as const } : r.outcome === 'exited' && r.exit === 127 ? classifyCommandEnvironment(guard.command, output, r.exit, env) : null;
|
|
return { guard, exit: r.exit, timedOut: r.outcome !== 'exited' && r.outcome !== 'spawn-failed', stalled: r.outcome === 'stalled', tail: output ? output.split('\n').slice(-15) : [], log, environment };
|
|
}
|
|
|
|
async function run(ctx: PregateContext): Promise<CheckResult> {
|
|
const touched = ctx.touched.flatMap(t => (t.renamedFrom ? [t.path, t.renamedFrom] : [t.path]));
|
|
const selected = ctx.config.guards.filter(g => touched.some(f => matchesAny(g.when, f)));
|
|
const inputs = baseInputs(ctx, { guards: sha256(selected.map(g => `${g.id}\0${g.command}`).join('\n')) });
|
|
const base: CheckResult = { id: 'guards', stage: 'preflight', status: 'pass', inputs };
|
|
if (ctx.config.guards.length === 0) return { ...base, detail: `no guards declared (${ctx.configLabel})` };
|
|
if (selected.length === 0) return { ...base, detail: `no guard's files touched (${ctx.config.guards.length} declared)` };
|
|
const listed = selected.map(g => `COMMAND: ${g.id}=${JSON.stringify(g.command)} when=${g.when.join(',')}`);
|
|
if (!ctx.allowRepoCommands) {
|
|
return { ...base, status: 'incomplete', code: 'REPO_COMMANDS_NOT_ALLOWED', lines: listed, detail: `${selected.length} guard(s) not run (pass --allow-repo-commands): ${selected.map(g => g.id).join(', ')}`, fix: 'rerun with --allow-repo-commands (what a lane runs before reporting done, and what /ship passes)' };
|
|
}
|
|
const runtime = launchRuntime(ctx.repoRoot, ctx.env);
|
|
if (runtime.refusal) return { ...base, ...runtime.refusal, cause: 'environment', environment: launchCause('runtime', runtime.refusal.detail), lines: [`runtime: ${runtime.label}`, environmentLine(launchCause('runtime', runtime.refusal.detail))] };
|
|
const dir = checkScratch(ctx.scratch, 'guards');
|
|
const runs: GuardRun[] = [];
|
|
for (const g of selected) {
|
|
const p = priorPass(ctx, g, runtime.env);
|
|
runs.push(p.prior ? { guard: g, exit: 0, timedOut: false, stalled: false, tail: [], log: '-', environment: null, ...p } : { ...(await runGuard(ctx, g, runtime.env, dir)), carryNone: p.carryNone });
|
|
}
|
|
const lines = [`runtime: ${runtime.label}`];
|
|
for (const r of runs) {
|
|
if (r.carryNone) lines.push(`CI_CARRY: none guard=${r.guard.id} (${r.carryNone})`);
|
|
if (r.prior) { lines.push(`GUARD: ${r.guard.id} ${r.prior}`); continue; }
|
|
lines.push(r.stalled ? `GUARD: ${r.guard.id} stalled: no output for ${ctx.stallTimeoutMs / 1000} s${STALL_IDLE}; process group killed (log ${r.log})` : r.timedOut ? `GUARD: ${r.guard.id} timed out after ${r.guard.timeout_s ?? ctx.timeoutMs / 1000} s (log ${r.log})` : `GUARD: ${r.guard.id} exit=${r.exit} (log ${r.log})`);
|
|
if (r.exit !== 0) lines.push(...r.tail.map(l => ` ${l}`));
|
|
if (r.environment) lines.push(` ${r.guard.id} ${environmentLine(r.environment)}`);
|
|
}
|
|
const blocked = runs.filter(r => r.environment);
|
|
const failed = runs.filter(r => !r.timedOut && !r.environment && r.exit !== 0);
|
|
const timedOut = runs.filter(r => r.timedOut);
|
|
const withRuns = { ...base, lines, inputs: { ...inputs, runtime: runtime.label } };
|
|
const fixOf = (rs: GuardRun[]) => rs.map(r => `${r.guard.id}: ${r.guard.fix ?? `read ${r.log}, fix the failure, rerun gstack-pregate --only guards`}`).join('; ');
|
|
if (failed.length) return { ...withRuns, status: 'fail', cause: 'diff', code: 'PREGATE_GUARD_FAILED', detail: `${failed.length} guard(s) failed: ${failed.map(r => r.guard.id).join(', ')}${timedOut.length ? `; timed out: ${timedOut.map(r => r.guard.id).join(', ')}` : ''}${blocked.length ? `; blocked by the environment: ${blocked.map(r => r.guard.id).join(', ')}` : ''}`, fix: fixOf(failed) };
|
|
if (blocked.length) return environmentResult(withRuns, blocked[0]!.environment!, `${blocked.length} guard(s) could not run on this machine: ${blocked.map(r => `${r.guard.id} (${r.environment!.kind})`).join(', ')}${timedOut.length ? `; timed out: ${timedOut.map(r => r.guard.id).join(', ')}` : ''}`);
|
|
if (timedOut.length) return { ...withRuns, status: 'incomplete', code: timedOut.some(r => r.stalled) ? 'PREGATE_STALLED' : 'PREGATE_INCOMPLETE', detail: `${timedOut.length} guard(s) did not finish: ${timedOut.map(r => `${r.guard.id} (${r.stalled ? 'stalled' : 'timed out'})`).join(', ')}`, fix: 'fix the hang, or raise the guard\'s timeout_s, --timeout or --stall-timeout; an unfinished guard never passes' };
|
|
const prior = runs.filter(r => r.prior).length;
|
|
return { ...withRuns, detail: `${runs.length} guard(s) passed: ${runs.map(r => r.guard.id).join(', ')}${prior ? ` (${prior} from an earlier pass at this tree)` : ''}` };
|
|
}
|
|
|
|
export const guardsCheck: CheckDef = { id: 'guards', stage: 'preflight', tier: 2, downgradable: false, needsRepoCommands: true, run };
|