mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
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
204 lines
12 KiB
JavaScript
Executable File
204 lines
12 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,
|
||
}) {
|
||
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);
|
||
}
|