mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 19:28:46 +00:00
The screenshot fingerprint and golden baselines stop being a tax on every task branch: - Task branches no longer commit docs/images/** or golden baselines. On a branch the screenshot freshness stays a preflight warning; the review prompt, REVIEWER.md and AUTHOR.md drop check-docs as a per-task gate. - beta-derived.yml refreshes them on dev in one bot commit before the beta candidate: canonical docs capture + docs:accept --reviewed, golden from the golden-images artifact of a completed Validate on dev + golden:accept --reviewed. A changed frame or scene is accepted only when named in the inputs; undeclared differences refuse. Baseline commits carry Release: and Baseline-Reviewed:; the subject is not a candidate subject. - classify-changes: the Release: trailer on an issue/* branch no longer switches on the heavy set. ci:full / ci:golden do: process-track emits full=true, the review gate dispatches Validate with full=true and does not accept a light proof. Canon: PROCESS.md §3 п.13, §5.1, §8, §11.4; CONTRIBUTING.md. Issue: #697 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
210 lines
13 KiB
JavaScript
Executable File
210 lines
13 KiB
JavaScript
Executable File
#!/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, full = false,
|
||
}) {
|
||
// #697: метки `ci:full`/`ci:golden` заказывают полный набор на материале —
|
||
// смоки, golden, perf. Лёгкий push-прогон его не несёт и доказательством не
|
||
// считается (policy.full), поэтому гейт диспатчит `full=true` сам.
|
||
const base = mutants ? CI_PROOF_POLICIES.review : CI_PROOF_POLICIES.reviewLight;
|
||
const policy = full ? Object.freeze({ ...base, name: `${base.name}-full`, full: true }) : base;
|
||
const label = `${mutants ? 'Validate с мутантами' : 'Validate'}${full ? ' (полный набор)' : ''}`;
|
||
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, full });
|
||
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, full });
|
||
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, full = false } = {}) => {
|
||
const r = sh('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', `full=${full ? 'true' : '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] [--full=true] [--no-wait]');
|
||
process.exit(2);
|
||
}
|
||
const wait = !process.argv.includes('--no-wait');
|
||
// #696: `--mutants=false` — лёгкое доказательство треков show/ship.
|
||
const mutants = arg('mutants') !== 'false';
|
||
// #697: `--full=true` — метки ci:full/ci:golden.
|
||
const full = arg('full') === 'true';
|
||
const outcome = await validateGate({ ref, sha, wait, mutants, full, 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);
|
||
}
|