mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Validate ran the three "Мутанты по диффу" shards on every push of every branch: 48 of 56 job-hours on 08–09.09, most of them cancelled by the next push. Mutants now run when asked — pull requests, the nightly schedule, a push carrying a `Release:` trailer, or a dispatch with `mutants=true` (classify-changes.mjs → `mutants_requested`); an ordinary push runs the light checks only. The proof moves to where it is consumed. process.yml gets a gate after the #499 reuse step: on the code stage it looks for a dispatch Validate run on the exact material SHA whose mutant jobs executed and passed (scripts/validate-gate.mjs); none → it dispatches one and waits; red or missing → the task goes back S7→S6 with the run link and the review cycle is not spent. Spec stage and the reuse fast-path skip the gate (`proceed=true`); all later steps branch on `proceed` in place of the old conflict conjunct only. merge-candidate.mjs dispatches Validate on the pushed candidate and waits for that dispatch run. PROCESS.md/AGENTS.md: review does not start on red code; one handoff — one push. Mutants: mutants-run-on-every-push, review-starts-on-red-validate, review-trusts-push-run-without-mutants, merge-waits-push-run-without-mutants. Issue: #510 User-Visible: no
121 lines
7.0 KiB
JavaScript
Executable File
121 lines
7.0 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]
|
||
*
|
||
* Печатает `result=green|red|missing` и `url=…` (и в $GITHUB_OUTPUT, если он
|
||
* задан); код выхода 0 только при green. Логика — чистая функция `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';
|
||
|
||
export const POLL_MS = 20_000;
|
||
|
||
/** Кандидат в доказательства: dispatch — только там мутанты могут быть запрошены. */
|
||
export function isMutantRun(run) {
|
||
return run?.event === 'workflow_dispatch';
|
||
}
|
||
|
||
export const 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 { listRuns(sha) → [{databaseId,status,conclusion,url,event,headSha}], listRunsOnRef(ref) → те же, jobs(runId) → [{name,conclusion}], dispatch(ref), sleep(ms), now() }
|
||
* @returns {Promise<{result:'green'|'red'|'missing', url:string|null, note:string}>}
|
||
*/
|
||
export async function validateGate({ ref, sha, ops, appearMs = VALIDATE_APPEAR_MS, totalMs = VALIDATE_TOTAL_MS, pollMs = POLL_MS }) {
|
||
const started = ops.now();
|
||
const ignored = new Set(); // завершённые зелёные dispatch без исполненных мутантов
|
||
let tracked = null;
|
||
let dispatchedAt = null;
|
||
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') {
|
||
if (run.conclusion !== 'success') return { result: 'red', url: run.url, note: `dispatch-прогон завершился: ${run.conclusion}` };
|
||
if (provesMutants(await ops.jobs(run.databaseId))) return { result: 'green', url: run.url, note: 'dispatch-прогон с исполненными мутантами зелёный' };
|
||
// зелёный, но мутанты не исполнялись (чужой dispatch без mutants=true) — не доказательство
|
||
ignored.add(run.databaseId);
|
||
tracked = null;
|
||
continue;
|
||
}
|
||
} else if (dispatchedAt === null) {
|
||
await ops.dispatch(ref);
|
||
dispatchedAt = ops.now();
|
||
} else if (ops.now() - dispatchedAt > appearMs) {
|
||
// Прогон должен был появиться. Если на ветке появился dispatch на другом
|
||
// SHA — материал сменился под ногами; иначе запуск просто не прошёл.
|
||
const elsewhere = (await ops.listRunsOnRef(ref)).filter(isMutantRun).find((x) => x.headSha && x.headSha !== sha);
|
||
return {
|
||
result: 'missing', url: elsewhere?.url || null,
|
||
note: elsewhere ? `материал сменился: dispatch-прогон стоит на ${String(elsewhere.headSha).slice(0, 8)}` : 'dispatch-прогон не появился за 3 минуты',
|
||
};
|
||
}
|
||
await ops.sleep(pollMs);
|
||
}
|
||
return { result: 'red', url: null, note: 'Validate с мутантами не завершился за 45 минут' };
|
||
}
|
||
|
||
const sh = (cmd, args) => spawnSync(cmd, args, { encoding: 'utf8' });
|
||
|
||
export function realOps({ repo, workflow = 'validate.yml' }) {
|
||
const fields = 'databaseId,status,conclusion,url,event,headSha';
|
||
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 })) : [];
|
||
},
|
||
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 outcome = await validateGate({ ref, sha, ops: realOps({ repo, workflow: arg('workflow') || 'validate.yml' }) });
|
||
const lines = [`result=${outcome.result}`, `url=${outcome.url || ''}`, `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`);
|
||
process.exit(outcome.result === 'green' ? 0 : 1);
|
||
}
|