mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-15 12:57:22 +01:00
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>
This commit is contained in:
1 parent
77f27fa083
commit
010df26ac6
10 files changed
+182
-30
No files matched your search
+8
-3
@@ -37,7 +37,7 @@ export type CarryRefused = { ok: false; reason: CarryRefusal; detail: string };
|
||||
* the runs' tree.json covers (`unit: 'files'`; tree.json has no per-file test counts yet), with named jobs `n/a`, and only
|
||||
* without either (the whole run carried) the runs' test count.
|
||||
*/
|
||||
export type CarryVerdict = { ok: true; runs: RunEvidence[]; tests: number | 'n/a'; unit?: 'files'; receipt: string } | CarryRefused;
|
||||
export type CarryVerdict = { ok: true; runs: RunEvidence[]; tests: number | 'n/a'; unit?: 'files'; notOwed?: string; receipt: string } | CarryRefused;
|
||||
|
||||
/** How many of HEAD's runs per workflow are tried, newest first. */
|
||||
const CANDIDATES = 10;
|
||||
@@ -51,7 +51,7 @@ function newestVerified(src: RunSource, name: string, profile: RunProfile, head:
|
||||
if (!listed.length) return no('no_run', `no ${profile.workflow} run (${profile.events.join(', ')}) on ${head.slice(0, 12)} yet`);
|
||||
const refusals: string[] = [];
|
||||
for (const run of listed) {
|
||||
const v = verifyRun(src.deps, { repo: src.repo, runRef: String(run.id), profileName: name, profile, vetoHeads: [head], now: src.now });
|
||||
const v = verifyRun(src.deps, { repo: src.repo, runRef: String(run.id), profileName: name, profile, vetoHeads: [head], now: src.now, unchanged: src.unchanged });
|
||||
if (v.ok) return v;
|
||||
refusals.push(`run ${run.id}: ${v.reason}`);
|
||||
}
|
||||
@@ -100,7 +100,12 @@ export function findCarry(src: RunSource, o: { profiles: string[]; head: string;
|
||||
}
|
||||
const after = o.wtree();
|
||||
if (after !== o.tree) return no('tree_changed', `the working tree changed while the runs were verified (now ${after ? after.slice(0, 12) : 'unreadable'})`);
|
||||
if (jobs.length) return { ok: true, runs, tests: 'n/a', receipt: `ci-carry tree=${o.tree.slice(0, 12)} jobs=${jobs.join(',')} ${runReceiptFields(runs, src.now)}` };
|
||||
if (jobs.length) {
|
||||
const gates = [...new Set(o.profiles.map(n => src.profiles[n]?.skippable?.gate).filter(Boolean))];
|
||||
const allSkipped = runs.every(e => jobs.every(j => e.skipped_jobs?.includes(j)));
|
||||
const notOwed = allSkipped ? `CI skipped at this tree (gate job ${gates.join(', ')}, workflow unchanged)` : undefined;
|
||||
return { ok: true, runs, tests: 'n/a', ...(notOwed ? { notOwed } : {}), receipt: `ci-carry tree=${o.tree.slice(0, 12)} jobs=${jobs.join(',')}${notOwed ? ` not_owed=${gates.join(',')}` : ''} ${runReceiptFields(runs, src.now)}` };
|
||||
}
|
||||
if (covered !== undefined) return { ok: true, runs, tests: covered, unit: 'files', receipt: `ci-carry tree=${o.tree.slice(0, 12)} files=${covered} ${runReceiptFields(runs, src.now)}` };
|
||||
const tests = runs.reduce((n, e) => n + (e.tests ?? e.files.length), 0);
|
||||
return { ok: true, runs, tests, receipt: `ci-carry tree=${o.tree.slice(0, 12)} tests=${tests} ${runReceiptFields(runs, src.now)}` };
|
||||
|
||||
@@ -261,7 +261,7 @@ function userPolicyChange(file: PregateFile): string | null {
|
||||
|
||||
export interface ClearInput { runUrl?: string; bundleLane?: string; receiptPath?: string; runs?: string[]; evidence?: RunCheck }
|
||||
/** What a run clear needs: the base branch's run profiles, the verifier's dependencies, the clock, and the remote gate's inventory. */
|
||||
export interface RunCheck { repo: string; deps: RunDeps; profiles: Record<string, RunProfile>; gateRuns: RemoteGateRuns | null; now: Date; inventory: (command: string, tree: string, selected?: string[]) => string[] | { error: string } }
|
||||
export interface RunCheck { repo: string; deps: RunDeps; profiles: Record<string, RunProfile>; gateRuns: RemoteGateRuns | null; now: Date; unchanged?: (p: RunProfile) => string | null; inventory: (command: string, tree: string, selected?: string[]) => string[] | { error: string } }
|
||||
export type ClearDecision = { receipt: string; attested: boolean; substitution?: PregateReceiptSubstitution } | { refused: 'usage' | 'PREGATE_RECEIPT_INVALID'; message: string; fix?: string };
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,7 @@ 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 { ghRunDeps, runInventory } from '../run-evidence';
|
||||
import { ghRunDeps, runInventory, type RunProfile } from '../run-evidence';
|
||||
import { wtreeFingerprint } from '../script-invocation';
|
||||
import { resolveBaseBranch } from '../ship-policy';
|
||||
import { normalizeRemoteUrl } from '../ship-queue-train-record';
|
||||
@@ -19,6 +19,38 @@ import { REMOTE_GATE_LANE } from './checks/lanes';
|
||||
import { PREGATE_CONFIG_PATH, isConfigUnavailable, loadConfigFromBase, loadConfigFromHead } from './config';
|
||||
import type { RunCheck } from './index';
|
||||
|
||||
/**
|
||||
* Null when a skippable profile's workflow and every repo file its gate job runs are byte-identical at HEAD and
|
||||
* origin/<base> (GST-1190); else the first that differs. The files are the profile's declared `scripts` plus every path the
|
||||
* gate job's block in the base workflow names that exists on either side, so a branch cannot move the skip decision.
|
||||
*/
|
||||
export function gateInputsChanged(root: string, base: string, p: RunProfile): string | null {
|
||||
const ref = `origin/${base.replace(/^origin\//, '')}`;
|
||||
const wf = `.github/workflows/${p.workflow}`;
|
||||
const gate = p.skippable?.gate;
|
||||
if (!gate) return 'the profile names no gate job';
|
||||
const blob = (rev: string, f: string) => { const r = spawnSync('git', ['rev-parse', '--verify', '--quiet', `${rev}:${f}`], { cwd: root, encoding: 'utf8', timeout: 30_000 }); return r.status === 0 ? r.stdout.trim() : null; };
|
||||
const show = spawnSync('git', ['show', `${ref}:${wf}`], { cwd: root, encoding: 'utf8', timeout: 30_000, maxBuffer: 8 * 1024 * 1024 });
|
||||
if (show.status !== 0) return `${wf} is not on ${ref}`;
|
||||
const lines = show.stdout.split('\n');
|
||||
const jobsAt = lines.findIndex(l => /^jobs:\s*$/.test(l));
|
||||
const blocks: string[][] = [];
|
||||
for (let i = jobsAt + 1; jobsAt >= 0 && i < lines.length && !/^\S/.test(lines[i]!); i++) {
|
||||
if (/^ [\w-]+:\s*$/.test(lines[i]!)) blocks.push([lines[i]!]);
|
||||
else blocks.at(-1)?.push(lines[i]!);
|
||||
}
|
||||
const block = blocks.find(b => b[0]!.trim() === `${gate}:` || b.some(l => l.trim() === `name: ${gate}`));
|
||||
if (!block) return `${wf} on ${ref} has no job ${gate}`;
|
||||
const named = [...new Set([...block.join('\n').matchAll(/[\w.\/-]+/g)].map(m => m[0].replace(/^\.\//, '')).filter(t => t.includes('/') || /\.(sh|ts|js|mjs|cjs|py|rb)$/.test(t)))];
|
||||
for (const f of [wf, ...(p.skippable!.scripts ?? []), ...named]) {
|
||||
const a = blob(ref, f);
|
||||
const b = blob('HEAD', f);
|
||||
if (a === null && b === null && f !== wf && !p.skippable!.scripts?.includes(f)) continue;
|
||||
if (a === null || a !== b) return `${f} differs from ${ref}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `profiled` says whether `lane` may have a run profile: an unreadable base counts as profiled, so a lane is
|
||||
* recorded unverified only once the base shows it has none. `policy: 'head'` reads the branch's profiles instead, only
|
||||
@@ -33,6 +65,7 @@ export function buildRunCheck(root: string, lane: string | null, allowRepoComman
|
||||
if (!repo) return { error: 'origin is not a github.com repository; run evidence is read from GitHub Actions', profiled: lane === null ? false : lane === REMOTE_GATE_LANE ? !!loaded.config.remote_gate_runs : !!loaded.config.run_profiles[lane] };
|
||||
return {
|
||||
repo, deps: ghRunDeps(repo, root), profiles: loaded.config.run_profiles, gateRuns: loaded.config.remote_gate_runs, now: new Date(),
|
||||
unchanged: p => gateInputsChanged(root, base, p),
|
||||
inventory: (command, tree, selected) => {
|
||||
if (!allowRepoCommands) return { error: 'the inventory command is a pin-file command; pass --allow-repo-commands' };
|
||||
const at = () => { const fp = wtreeFingerprint({ cwd: root }); return 'tree' in fp ? fp.tree : null; };
|
||||
|
||||
+30
-7
@@ -42,7 +42,11 @@ import * as path from 'node:path';
|
||||
import { sha256Hex, writeReceipt } from './egress-receipt';
|
||||
import { runBashSync } from './script-invocation';
|
||||
|
||||
export interface RunProfile { workflow: string; events: string[]; jobs: string[]; artifact: string; max_age_hours?: number; os?: string }
|
||||
/**
|
||||
* `skippable` (GST-1190): named jobs the workflow may skip by path; a skip counts only when the `gate` job succeeded and the
|
||||
* workflow plus every repo file the gate job runs (`scripts` and any path its steps name) match origin/<base> byte for byte.
|
||||
*/
|
||||
export interface RunProfile { workflow: string; events: string[]; jobs: string[]; artifact: string; max_age_hours?: number; os?: string; skippable?: { jobs: string[]; gate: string; scripts?: string[] } }
|
||||
export interface RemoteGateRuns { profiles: string[]; inventory: string; skipped?: string }
|
||||
export interface ApiRun {
|
||||
id: number; run_attempt: number; status: string; conclusion: string | null; event: string; head_sha: string;
|
||||
@@ -65,12 +69,12 @@ export type RunRefusal = 'run_ref_invalid' | 'other_repository' | 'fork_run' | '
|
||||
| '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';
|
||||
/** `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 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[]; skipped_jobs?: string[] }
|
||||
export type RunVerdict = { ok: true; evidence: RunEvidence } | { ok: false; reason: RunRefusal; detail: string };
|
||||
|
||||
const SHA = /^[0-9a-f]{40}$/;
|
||||
const RUN_EVENTS = ['push', 'workflow_dispatch', 'pull_request'];
|
||||
const PROFILE_KEYS = ['workflow', 'events', 'jobs', 'artifact', 'max_age_hours', 'os'];
|
||||
const PROFILE_KEYS = ['workflow', 'events', 'jobs', 'artifact', 'max_age_hours', 'os', 'skippable'];
|
||||
const WORKFLOW_FILE = /^[A-Za-z0-9_.-]+\.ya?ml$/;
|
||||
const NAME = /^[A-Za-z0-9_.\/-]{1,128}$/;
|
||||
export const DEFAULT_MAX_AGE_HOURS = 24;
|
||||
@@ -105,6 +109,14 @@ export function runProfileErrors(profiles: unknown, gateRuns: unknown): Array<{
|
||||
if (typeof p.artifact !== 'string' || !NAME.test(p.artifact)) errors.push({ path: `${at}.artifact`, message: 'must be the artifact name holding tree.json' });
|
||||
if ('max_age_hours' in p && (!Number.isInteger(p.max_age_hours) || (p.max_age_hours as number) < 1 || (p.max_age_hours as number) > 168)) errors.push({ path: `${at}.max_age_hours`, message: 'must be an integer from 1 to 168' });
|
||||
if ('os' in p && (typeof p.os !== 'string' || !/^(Linux|macOS|Windows)$/.test(p.os))) errors.push({ path: `${at}.os`, message: 'must be Linux, macOS or Windows' });
|
||||
if ('skippable' in p) {
|
||||
const s = p.skippable as Record<string, unknown> | null;
|
||||
const names = (v: unknown) => Array.isArray(v) && v.length > 0 && v.length <= 32 && v.every(x => typeof x === 'string' && !!x.trim() && x.length <= 128);
|
||||
const repoPath = (x: unknown) => typeof x === 'string' && !!x && !x.startsWith('/') && !x.split('/').includes('..') && !/[\0\r\n]/.test(x);
|
||||
if (!s || typeof s !== 'object' || Array.isArray(s) || Object.keys(s).some(k => !['jobs', 'gate', 'scripts'].includes(k)) || !names(s.jobs) || typeof s.gate !== 'string' || !s.gate.trim()
|
||||
|| (s.jobs as string[]).includes(s.gate as string) || ('scripts' in s && (!Array.isArray(s.scripts) || !s.scripts.every(repoPath))))
|
||||
errors.push({ path: `${at}.skippable`, message: 'must be { jobs: [job names], gate: <the job that decides the skip>, scripts?: [repo paths the gate runs] }, the gate not among the jobs' });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (gateRuns !== undefined && gateRuns !== null) {
|
||||
@@ -133,7 +145,7 @@ export const normalPath = (f: string): string => f.replace(/^(\.\/)+/, '');
|
||||
* The verdict for run `runRef` under `profile`. `vetoHeads` are further heads
|
||||
* whose newer runs of the same workflow can veto (the consumer's own head).
|
||||
*/
|
||||
export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; profileName: string; profile: RunProfile; vetoHeads?: string[]; now: Date }): RunVerdict {
|
||||
export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; profileName: string; profile: RunProfile; vetoHeads?: string[]; now: Date; unchanged?: (p: RunProfile) => string | null }): RunVerdict {
|
||||
const id = parseRunRef(o.runRef, o.repo);
|
||||
if (id === null) return fail('run_ref_invalid', `${JSON.stringify(o.runRef)} is not a run id or a run URL on ${o.repo}`);
|
||||
const p = o.profile;
|
||||
@@ -154,11 +166,21 @@ export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; prof
|
||||
const maxAge = p.max_age_hours ?? DEFAULT_MAX_AGE_HOURS;
|
||||
if (!Number.isFinite(finished)) return fail('api_error', `${at} has no completion time`);
|
||||
if (o.now.getTime() - finished > maxAge * 3600_000) return fail('run_stale', `${at} finished ${finishedAt}, more than ${maxAge} h before now`);
|
||||
const skippedJobs: string[] = [];
|
||||
for (const name of p.jobs) {
|
||||
const hits = jobs.filter(j => j.name === name);
|
||||
if (hits.length === 0) return fail('job_missing', `${at} has no job named ${name}`);
|
||||
if (hits.length > 1) return fail('job_ambiguous', `${at} has ${hits.length} jobs named ${name}`);
|
||||
if (hits[0]!.conclusion === 'skipped') return fail('job_skipped', `${at}'s ${name} job was skipped`);
|
||||
if (hits[0]!.conclusion === 'skipped') {
|
||||
const skip = p.skippable;
|
||||
if (!skip?.jobs.includes(name)) return fail('job_skipped', `${at}'s ${name} job was skipped`);
|
||||
const gate = jobs.filter(j => j.name === skip.gate);
|
||||
if (gate.length !== 1 || gate[0]!.conclusion !== 'success') return fail('job_skipped', `${at}'s ${name} job was skipped and its gate job ${skip.gate} ${gate.length === 1 ? `concluded ${gate[0]!.conclusion}` : gate.length ? 'is ambiguous' : 'is missing'}`);
|
||||
const changed = o.unchanged ? o.unchanged(p) : 'no base copy of the workflow was compared';
|
||||
if (changed) return fail('job_skipped', `${at}'s ${name} job was skipped, but ${changed}, so this branch decided the skip`);
|
||||
skippedJobs.push(name);
|
||||
continue;
|
||||
}
|
||||
if (hits[0]!.conclusion !== 'success') return fail('job_not_success', `${at}'s ${name} job concluded ${hits[0]!.conclusion}`);
|
||||
}
|
||||
const heads = [...new Set([run.head_sha, ...(o.vetoHeads ?? []).filter(h => SHA.test(h))])];
|
||||
@@ -194,7 +216,7 @@ export function verifyRun(deps: RunDeps, o: { repo: string; runRef: string; prof
|
||||
const changed = runChanged(deps, id, run.run_attempt);
|
||||
if (changed) return fail('run_changed', changed);
|
||||
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 } };
|
||||
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, ...(skippedJobs.length ? { skipped_jobs: skippedJobs } : {}) } };
|
||||
} catch (e: any) {
|
||||
return fail('api_error', `reading run ${id} failed: ${String(e?.message ?? e).slice(0, 300)}`);
|
||||
}
|
||||
@@ -227,7 +249,8 @@ export function uncoveredFiles(inventory: string[], evidence: RunEvidence[], ski
|
||||
}
|
||||
|
||||
/** What a run check reads: the repository, the verifier's dependencies, the base branch's profiles and the clock. */
|
||||
export interface RunSource { repo: string; deps: RunDeps; profiles: Record<string, RunProfile>; now: Date }
|
||||
/** `unchanged` (findCarry only): null when a profile's workflow and gate inputs match the base, else which file differs. */
|
||||
export interface RunSource { repo: string; deps: RunDeps; profiles: Record<string, RunProfile>; now: Date; unchanged?: (p: RunProfile) => string | null }
|
||||
export type FromRunVerdict = { ok: true; evidence: RunEvidence; receipt: string } | { ok: false; reason: string; detail: string };
|
||||
|
||||
/**
|
||||
|
||||
@@ -172,7 +172,7 @@ export function laneConcurrency(mode: string | undefined, cpus = os.availablePar
|
||||
return cpus <= 4 ? 1 : Infinity;
|
||||
}
|
||||
|
||||
export interface LaneResult { label: string; exit: number | null; testsRan: string; log: string; carried?: string; reused?: string; carryNone?: string }
|
||||
export interface LaneResult { label: string; exit: number | null; testsRan: string; log: string; carried?: string; reused?: string; carryNone?: string; notOwed?: string }
|
||||
|
||||
/** The base branch's `ship_lanes_from` entry for each lane whose command is a key or a declared alias, byte for byte (the ledger binds its sha256). */
|
||||
export function mappedLanes(cwd: string, base: string, list: { label: string; command: string }[]): Map<string, ShipLaneMapping> {
|
||||
@@ -191,7 +191,8 @@ export function mappedLanes(cwd: string, base: string, list: { label: string; co
|
||||
export function carryLane(cwd: string, base: string, l: { label: string; command: string }): LaneResult {
|
||||
const rec = bin(cwd, 'gstack-evidence', ['record-ci', '--label', l.label, '--base', base, '--', l.command], 600_000);
|
||||
const got = / tests_ran=(\d+|n\/a)( unit=files)? log=- source=ci runs=(\S+)/.exec(rec.stderr);
|
||||
if (rec.status === 0 && got) return { label: l.label, exit: 0, testsRan: got[2] ? `${got[1]} (files)` : got[1]!, log: '-', carried: got[3]! };
|
||||
const notOwed = /^gstack-evidence: not owed: (.+)$/m.exec(rec.stderr)?.[1];
|
||||
if (rec.status === 0 && got) return { label: l.label, exit: 0, testsRan: got[2] ? `${got[1]} (files)` : got[1]!, log: '-', carried: got[3]!, ...(notOwed ? { notOwed } : {}) };
|
||||
return { label: l.label, exit: null, testsRan: 'unknown', log: '-', carryNone: /^CARRY_NONE: (.+)$/m.exec(rec.stderr)?.[1] ?? `record_failed: ${firstLine(rec.stderr)}` };
|
||||
}
|
||||
|
||||
@@ -223,6 +224,7 @@ export async function runLanes(cwd: string, list: { label: string; command: stri
|
||||
|
||||
/** Step 5's lane verdict: a lane passes on its runner count, never exit 0 alone; a carried lane names its CI runs. */
|
||||
export function laneLine(l: LaneResult): { line: string; ok: boolean } {
|
||||
if (l.notOwed) return { line: `LANE: ${l.label} not owed: ${l.notOwed} run=${l.carried}`, ok: true };
|
||||
if (l.carried) return { line: `LANE: ${l.label} carried run=${l.carried} tests_ran=${l.testsRan}`, ok: true };
|
||||
const zero = l.exit === 0 && l.testsRan === '0';
|
||||
const state = l.exit !== 0 ? 'FAIL' : zero ? 'ZERO-RUN' : l.testsRan === 'unknown' ? 'pass (count unavailable)' : 'pass';
|
||||
|
||||
Reference in new issue
Block a user