Files
houseplan-card/scripts/validate-gate.mjs
T
Claudeandclaude[bot] 1514899746 ci: имена job validate.yml — контракт, сверяемый с файлом; шарды из матрицы (#622)
- scripts/workflow-jobs.mjs: разбор job-уровня validate.yml (id, name,
  inline matrix) без зависимостей; незнакомые формы — громкая ошибка.
- ci-proof: JOB_RULES по id job с именем-контрактом; число и точные имена
  экземпляров — из матрицы YAML (константы count: 6 нет); resolveJobRules
  на каждом evaluateCiProof — переименование даёт failed с названной job;
  jobContractProblems — сверка в обе стороны, UNCONSUMED_JOBS для proof.
- validate-gate: MUTANT_JOB_PREFIX — из контракта ci-proof.
- e2e-gate: E2E_JOB_NAME — зеркало name: из houseplan-e2e e2e.yml
  (43899da5), распознавание выводится из шаблона; missing называет
  завершённые прогоны без job по контракту.
- тесты: фикстуры имён выводятся из validate.yml; новый
  test/workflow-jobs.test.mjs; 5 мутантов в реестре.

Issue: #622
User-Visible: no
2026-09-24 01:35:25 +00:00

181 lines
11 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Validate с мутантами на материале ревью — до того, как ревьюер потратит
* цикл (#510 §5).
*
* Мутанты по диффу бегут только по запросу (`validate.yml`, `mutants=true`), и
* доказательство для ревью — dispatch-прогон на точном SHA материала. Push-
* прогон на том же SHA зелёный не считается: в нём мутантов нет.
*
* node scripts/validate-gate.mjs --repo=<owner/repo> --ref=<ветка> --sha=<sha> [--workflow=validate.yml] [--no-wait]
*
* Печатает `result=green|failed|missing|pending`, `url=…`, `run_id=…` (и в
* $GITHUB_OUTPUT, если он задан); код выхода 0 только при green, 2 — pending.
* `--no-wait` (#636): гейт диспатчит прогон и убеждается, что тот появился на
* материале, но не ждёт его завершения — раннер конвейера освобождается, а
* раунд продолжает `process-resume.yml` по событию `workflow_run` (страховка —
* reconcile). Зелёный или красный завершённый прогон и с `--no-wait`
* возвращается сразу. Логика — чистая функция `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, MUTANT_JOB_PREFIX, 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';
}
// #622: префикс — из контракта ci-proof (JOB_RULES.mutants), который сверяется
// с validate.yml; своей строки здесь больше нет.
export { 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.
* @param {boolean} [p.wait] `false` — не ждать идущий прогон, а вернуть `pending` (#636):
* раннер конвейера не спит 28 минут; продолжение разбудит событие
* `workflow_run` (process-resume.yml) либо reconcile.
* @returns {Promise<{result:'green'|'failed'|'missing'|'pending', url:string|null, note:string, runId?:number}>}
*/
export async function validateGate({
ref, sha, ops, appearMs = VALIDATE_APPEAR_MS, totalMs = VALIDATE_TOTAL_MS, pollMs = POLL_MS, wait = true,
}) {
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;
}
if (!wait) {
// #636: прогон найден и идёт — ждать его будет событие, не раннер.
return {
result: 'pending', url: run.url || null, runId: run.databaseId,
note: `Validate с мутантами идёт (${run.status}); продолжение — по завершении прогона`,
};
}
} 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=<owner/repo> --ref=<branch> --sha=<sha> [--workflow=validate.yml]');
process.exit(2);
}
const wait = !process.argv.includes('--no-wait');
const outcome = await validateGate({ ref, sha, wait, ops: realOps({ repo, workflow: arg('workflow') || 'validate.yml' }) });
const lines = [`result=${outcome.result}`, `url=${outcome.url || ''}`, `run_id=${outcome.runId || ''}`, `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`);
// 0 — зелёный, 2 — идёт (только с --no-wait), 1 — красный/пропавший.
process.exit(outcome.result === 'green' ? 0 : outcome.result === 'pending' ? 2 : 1);
}