#!/usr/bin/env node /** * Validate с мутантами на материале ревью — до того, как ревьюер потратит * цикл (#510 §5). * * Мутанты по диффу бегут только по запросу (`validate.yml`, `mutants=true`), и * доказательство для ревью — dispatch-прогон на точном SHA материала. Push- * прогон на том же SHA зелёный не считается: в нём мутантов нет. * * node scripts/validate-gate.mjs --repo= --ref=<ветка> --sha= [--workflow=validate.yml] * * Печатает `result=green|failed|missing` и `url=…` (и в $GITHUB_OUTPUT, если он * задан); код выхода 0 только при green. Логика — чистая функция `validateGate` * поверх инъектируемых `ops`, чтобы тесты и мутанты гоняли её без gh. */ import { spawnSync } from 'node:child_process'; import { appendFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { resolve } from 'node:path'; import { VALIDATE_APPEAR_MS, VALIDATE_TOTAL_MS } from './merge-candidate.mjs'; import { CI_PROOF_POLICIES, evaluateCiProof, githubCandidateTree, loadGithubProofContext, } from './ci-proof.mjs'; export const POLL_MS = 20_000; /** * Сколько раз гейт пробует запустить свой dispatch (#539). * * `workflow_dispatch` в API принимает только ref, а не SHA: имя ветки * резолвится на стороне GitHub в момент запуска. Конвейер перед этим сам * переписывает ветку ребейзом, и 12.09 на #536 диспатч, отправленный через * три секунды после force-push, встал на ДОпушевый SHA — гейт не нашёл прогона * на материале и вернул задачу автору, которому чинить было нечего. Вторая * попытка закрывает это окно: ссылка к тому времени доезжает гарантированно. */ export const DISPATCH_ATTEMPTS = 2; /** Кандидат в доказательства: dispatch — только там мутанты могут быть запрошены. */ export function isMutantRun(run) { return run?.event === 'workflow_dispatch'; } export const MUTANT_JOB_PREFIX = 'Мутанты по диффу'; /** * Зелёный dispatch доказывает мутанты, только если их job реально исполнены * (ревью ТЗ r1): чужой dispatch с `mutants=false` на том же SHA тоже зелёный, * но с `changed_mutants: skipped`. `validate.yml` при запросе исполняет job * даже на пустом отборе, поэтому skipped однозначно значит «не запрашивали». */ export function provesMutants(jobs) { const mutantJobs = (Array.isArray(jobs) ? jobs : []).filter((job) => String(job?.name || '').startsWith(MUTANT_JOB_PREFIX)); return mutantJobs.length > 0 && mutantJobs.every((job) => job.conclusion === 'success'); } /** * @param {object} p * @param {string} p.ref ветка, на которой запускать * @param {string} p.sha SHA материала * @param {object} p.ops GitHub run/proof operations plus dispatch, sleep and clock. * @returns {Promise<{result:'green'|'failed'|'missing', url:string|null, note:string}>} */ export async function validateGate({ ref, sha, ops, appearMs = VALIDATE_APPEAR_MS, totalMs = VALIDATE_TOTAL_MS, pollMs = POLL_MS }) { const started = ops.now(); const candidateTree = await ops.candidateTree(sha); const ignored = new Set(); // завершённые dispatch без применимого proof let tracked = null; let dispatchedAt = null; let attempts = 0; while (ops.now() - started < totalMs) { const runs = (await ops.listRuns(sha)).filter((x) => isMutantRun(x) && !ignored.has(x.databaseId)); const run = runs.find((x) => tracked && x.databaseId === tracked) || runs[0]; if (run) { tracked = run.databaseId; if (run.status === 'completed') { const context = await ops.proof(run); const verdict = evaluateCiProof({ run, ...context, candidate: { sha, tree: candidateTree }, policy: CI_PROOF_POLICIES.review, }); if (verdict.status === 'green') return { result: 'green', url: verdict.url, note: verdict.note }; if (verdict.status === 'failed') return { result: 'failed', url: verdict.url, note: verdict.note }; // cancelled, light, stale или legacy run без proof — не доказательство. ignored.add(run.databaseId); tracked = null; continue; } } else if (dispatchedAt === null) { await ops.dispatch(ref); dispatchedAt = ops.now(); attempts = 1; } else if (ops.now() - dispatchedAt > appearMs) { // Прогон должен был появиться. Если на ветке стоит dispatch на другом // SHA — либо ссылка ветки в момент запуска ещё отдавала прежнюю вершину // (#539: конвейер сам переписал её ребейзом за секунды до этого), либо // материал действительно сменился. Различать гадательно нечем, поэтому // гейт сначала пробует ещё раз: собственная гонка этим закрывается, а // чужой коммит переживёт и вторую попытку. const elsewhere = (await ops.listRunsOnRef(ref)).filter(isMutantRun).find((x) => x.headSha && x.headSha !== sha); if (elsewhere && attempts < DISPATCH_ATTEMPTS) { await ops.dispatch(ref); dispatchedAt = ops.now(); attempts += 1; await ops.sleep(pollMs); continue; } return { result: 'missing', url: elsewhere?.url || null, note: elsewhere ? `ссылка ветки не указывает на материал: ${attempts} попыт(ки) диспатча встали на ${String(elsewhere.headSha).slice(0, 8)}` : 'dispatch-прогон не появился за 3 минуты', }; } await ops.sleep(pollMs); } return { result: 'failed', url: null, note: 'Validate с мутантами не завершился за 45 минут' }; } const sh = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8' }); export function realOps({ repo, workflow = 'validate.yml', token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN }) { const fields = 'databaseId,status,conclusion,url,event,headSha,attempt,startedAt,createdAt'; const parse = (r) => (r.status === 0 && r.stdout ? JSON.parse(r.stdout) : []); return { listRuns: async (sha) => parse(sh('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--commit', sha, '--json', fields, '--limit', '20'])), jobs: async (runId) => { const r = sh('gh', ['run', 'view', String(runId), '--repo', repo, '--json', 'jobs']); return r.status === 0 && r.stdout ? (JSON.parse(r.stdout).jobs || []).map((job) => ({ name: job.name, conclusion: job.conclusion })) : []; }, candidateTree: (sha) => githubCandidateTree({ repo, sha, token }), proof: async (run) => { try { return await loadGithubProofContext({ repo, run, token }); } catch { return { proof: null, jobs: [], reuseRuns: new Map() }; } }, listRunsOnRef: async (ref) => parse(sh('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--branch', ref, '--event', 'workflow_dispatch', '--json', fields, '--limit', '5'])), dispatch: async (ref) => { const r = sh('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', 'mutants=true']); if (r.status !== 0) throw new Error(`gh workflow run: ${r.stderr || r.stdout}`); }, sleep: (ms) => new Promise((done) => setTimeout(done, ms)), now: () => Date.now(), }; } const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); if (invokedDirectly) { const arg = (name) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3); const repo = arg('repo') || process.env.GITHUB_REPOSITORY; const ref = arg('ref'); const sha = arg('sha'); if (!repo || !ref || !sha) { console.error('usage: validate-gate.mjs --repo= --ref= --sha= [--workflow=validate.yml]'); process.exit(2); } const outcome = await validateGate({ ref, sha, ops: realOps({ repo, workflow: arg('workflow') || 'validate.yml' }) }); const lines = [`result=${outcome.result}`, `url=${outcome.url || ''}`, `note=${outcome.note}`]; for (const line of lines) console.log(line); if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join('\n')}\n`); process.exit(outcome.result === 'green' ? 0 : 1); }