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
This commit is contained in:
Claude
2026-09-28 23:09:46 +03:00
parent 9e4bfb4379
commit e1ae8f4ac7
22 changed files with 1585 additions and 94 deletions
+35 -12
View File
@@ -7,7 +7,7 @@
* доказательство для ревью — dispatch-прогон на точном SHA материала. Push-
* прогон на том же SHA зелёный не считается: в нём мутантов нет.
*
* node scripts/validate-gate.mjs --repo=<owner/repo> --ref=<ветка> --sha=<sha> [--workflow=validate.yml] [--no-wait]
* 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.
@@ -45,6 +45,19 @@ 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 };
@@ -72,7 +85,10 @@ export function provesMutants(jobs) {
*/
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
@@ -80,14 +96,19 @@ export async function validateGate({
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];
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: CI_PROOF_POLICIES.review,
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 };
@@ -100,11 +121,11 @@ export async function validateGate({
// #636: прогон найден и идёт — ждать его будет событие, не раннер.
return {
result: 'pending', url: run.url || null, runId: run.databaseId,
note: `Validate с мутантами идёт (${run.status}); продолжение — по завершении прогона`,
note: `${label} идёт (${run.status}); продолжение — по завершении прогона`,
};
}
} else if (dispatchedAt === null) {
await ops.dispatch(ref);
await ops.dispatch(ref, { mutants });
dispatchedAt = ops.now();
attempts = 1;
} else if (ops.now() - dispatchedAt > appearMs) {
@@ -116,7 +137,7 @@ export async function validateGate({
// чужой коммит переживёт и вторую попытку.
const elsewhere = (await ops.listRunsOnRef(ref)).filter(isMutantRun).find((x) => x.headSha && x.headSha !== sha);
if (elsewhere && attempts < DISPATCH_ATTEMPTS) {
await ops.dispatch(ref);
await ops.dispatch(ref, { mutants });
dispatchedAt = ops.now();
attempts += 1;
await ops.sleep(pollMs);
@@ -131,7 +152,7 @@ export async function validateGate({
}
await ops.sleep(pollMs);
}
return { result: 'failed', url: null, note: 'Validate с мутантами не завершился за 45 минут' };
return { result: 'failed', url: null, note: `${label} не завершился за 45 минут` };
}
const sh = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8' });
@@ -151,8 +172,8 @@ export function realOps({ repo, workflow = 'validate.yml', token = process.env.G
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']);
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)),
@@ -167,11 +188,13 @@ if (invokedDirectly) {
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]');
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');
const outcome = await validateGate({ ref, sha, wait, ops: realOps({ repo, workflow: arg('workflow') || 'validate.yml' }) });
// #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`);