Files
houseplan-card/scripts/validate-gate.mjs
T
Claude e1ae8f4ac7 process: the review pipeline prices each round by track (#696)
show/ship stop paying for diff mutants and for every move of dev:

- scripts/process-track.mjs resolves the track from the current labels and
  the diff (show for unlabelled infra, ask for unlabelled product work) and
  checks the mechanical ship limits; outside them the pipeline comments and
  relabels track:ship -> track:show in the same round.
- Validate on the review material is light on show/ship: a completed push
  run on the exact SHA is proof, a dispatch asks mutants=false. ask and the
  ci:mutants label keep the mutant dispatch.
- show/ship skip the pre-review rebase when git merge-tree with dev is
  clean; the candidate is rebased once at merge and still passes Validate
  before the push to dev. The light merge waits for the push run of the
  candidate and dispatches only when none appears.
- ship inside the limits merges after the light Validate without a model
  review; the issue gets a machine marker hp:ship-merge.
- ship-review.yml + scripts/ship-review.mjs read the code of all ship
  tasks of a beta range in one model session and publish
  docs/reviews/SHIP-REVIEW-<tag>.md; both beta publication paths refuse a
  range with ship tasks the document does not cover or that carries a High.
- show reviews judge correctness and AC; the spec review installs neither
  npm ci nor Chromium, the show review installs Chromium only when the issue
  names a smoke.

Canon: PROCESS.md §5, §5.1, §10.4, new §11.7; REVIEWER.md, AUTHOR.md and
AGENTS.md digests.

Issue: #696
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-28 23:09:46 +03:00

204 lines
12 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] [--mutants=false]
*
* Печатает `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';
}
/**
* #696: какие прогоны годятся в доказательство. С мутантами — только dispatch
* (push их не запрашивает). Без мутантов (`show`/`ship`) годится и
* завершённый push-прогон на материале: лёгкий Validate на этом SHA уже
* исполнен, и dispatch повторил бы его. Ждать же можно только dispatch:
* продолжение раунда будит его завершение (process-resume.yml слушает
* `workflow_dispatch`), и push, который ещё идёт, раунд не разбудил бы.
*/
export function proofCandidate(run, { mutants = true } = {}) {
if (mutants) return isMutantRun(run);
return run?.event === 'workflow_dispatch' || (run?.event === 'push' && run?.status === 'completed');
}
// #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,
mutants = true,
}) {
const policy = mutants ? CI_PROOF_POLICIES.review : CI_PROOF_POLICIES.reviewLight;
const label = mutants ? 'Validate с мутантами' : 'Validate';
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) => proofCandidate(x, { mutants }) && !ignored.has(x.databaseId));
// Без мутантов завершённый прогон на материале предпочтительнее идущего
// dispatch: доказательство уже есть, ждать нечего (#696). С мутантами
// порядок прежний — свежий прогон решает.
const run = runs.find((x) => tracked && x.databaseId === tracked)
|| (!mutants && runs.find((x) => x.status === 'completed'))
|| 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,
});
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: `${label} идёт (${run.status}); продолжение — по завершении прогона`,
};
}
} else if (dispatchedAt === null) {
await ops.dispatch(ref, { mutants });
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, { mutants });
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: `${label} не завершился за 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, { mutants = true } = {}) => {
const r = sh('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', `mutants=${mutants ? 'true' : 'false'}`]);
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] [--mutants=false] [--no-wait]');
process.exit(2);
}
const wait = !process.argv.includes('--no-wait');
// #696: `--mutants=false` — лёгкое доказательство треков show/ship.
const mutants = arg('mutants') !== 'false';
const outcome = await validateGate({ ref, sha, wait, mutants, 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);
}