mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 05:08:53 +00:00
ci: diff mutants only on request; the review pipeline proves them on the material before reviewing
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
This commit is contained in:
@@ -94,6 +94,20 @@ export function heavyGatesRequested({ eventName, headMessage, fullInput } = {})
|
||||
return hasReleaseTrailer(headMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Нужны ли мутанты по диффу (#510). За 08–09.09 они съели 86 % job-минут
|
||||
* Validate, потому что бежали на каждом промежуточном пуше и отменялись
|
||||
* следующим. Место мутантов — кандидат: ревью-конвейер и слияние кандидата
|
||||
* запускают Validate по кнопке с `mutants=true`, ночной прогон и PR берут
|
||||
* полный набор, кандидат беты несёт трейлер `Release:`. Обычный push — нет.
|
||||
*/
|
||||
export function mutantsRequested({ eventName, headMessage, fullInput, mutantsInput } = {}) {
|
||||
if (eventName === 'pull_request') return true;
|
||||
if (eventName === 'schedule') return true;
|
||||
if (eventName === 'workflow_dispatch') return String(fullInput) === 'true' || String(mutantsInput) === 'true';
|
||||
return hasReleaseTrailer(headMessage);
|
||||
}
|
||||
|
||||
/** Трейлер `Release: vX.Y.Z` в конце сообщения коммита — признак кандидата. */
|
||||
export function hasReleaseTrailer(message) {
|
||||
return /^Release:\s*v?\d+\.\d+\.\d+\S*\s*$/m.test(String(message || ''));
|
||||
@@ -119,6 +133,13 @@ if (invokedDirectly) {
|
||||
fullInput: process.env.FULL_INPUT,
|
||||
});
|
||||
process.stdout.write(`heavy=${heavy ? 'true' : 'false'}\n`);
|
||||
const mutants = mutantsRequested({
|
||||
eventName: process.env.EVENT_NAME,
|
||||
headMessage: process.env.HEAD_MESSAGE,
|
||||
fullInput: process.env.FULL_INPUT,
|
||||
mutantsInput: process.env.MUTANTS_INPUT,
|
||||
});
|
||||
process.stdout.write(`mutants_requested=${mutants ? 'true' : 'false'}\n`);
|
||||
} else {
|
||||
const all = process.argv.includes('--all');
|
||||
const outputs = all ? classifyAll() : classifyChanges(readFileSync(0, 'utf8'));
|
||||
|
||||
@@ -127,12 +127,20 @@ export function realOps({ repo, token, workflow = 'validate.yml', sleep = (ms) =
|
||||
if (/stale info|rejected|fetch first|lease/i.test(r.stderr)) return false;
|
||||
throw new Error(`git push ${ref}: ${r.stderr}`);
|
||||
},
|
||||
waitValidate: async (sha) => {
|
||||
// Мутанты по диффу бегут только по запросу (#510): кандидат после ребейза —
|
||||
// новое дерево, поэтому слияние запускает Validate с мутантами само и ждёт
|
||||
// именно этот dispatch-прогон; push-прогон на том же SHA их не содержит.
|
||||
dispatchValidate: (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 ${workflow}: ${r.stderr || r.stdout}`);
|
||||
},
|
||||
waitValidate: async (sha, { event = 'workflow_dispatch' } = {}) => {
|
||||
const started = now();
|
||||
let runId = null;
|
||||
while (now() - started < VALIDATE_TOTAL_MS) {
|
||||
const r = sh('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--commit', sha, '--json', 'databaseId,status,conclusion,url', '--limit', '5']);
|
||||
const runs = r.status === 0 && r.stdout ? JSON.parse(r.stdout) : [];
|
||||
const r = sh('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--commit', sha, '--json', 'databaseId,status,conclusion,url,event', '--limit', '10']);
|
||||
const all = r.status === 0 && r.stdout ? JSON.parse(r.stdout) : [];
|
||||
const runs = all.filter((x) => !event || x.event === event);
|
||||
const run = runs.find((x) => x.databaseId === runId) || runs[0];
|
||||
if (run) {
|
||||
runId = run.databaseId;
|
||||
@@ -200,8 +208,10 @@ export async function mergeCandidate({ branch, material, issue, ops, maxAttempts
|
||||
tip = candidate;
|
||||
if (!patchIdEqual) return finish(decideMerge({ fresh: true, devMoved: true, patchIdEqual: false }), { candidate, devNow });
|
||||
|
||||
ops.log(`Validate на кандидате ${candidate.slice(0, 8)} — ждём`);
|
||||
const { result, url } = await ops.waitValidate(candidate);
|
||||
// мутанты по диффу — по запросу (#510): dispatch на ветке, где теперь стоит кандидат
|
||||
ops.dispatchValidate(branch);
|
||||
ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);
|
||||
const { result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' });
|
||||
let decision = decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: result, attempt, maxAttempts });
|
||||
if (decision.action !== 'push') return finish(decision, { candidate, devNow, runUrl: url });
|
||||
|
||||
|
||||
@@ -3694,7 +3694,7 @@ const MUTANT_DEFINITIONS = [
|
||||
+ 'it without a green Validate on that SHA is the false-green the audit reproduced (#492 §4)',
|
||||
patches: [{
|
||||
file: 'scripts/merge-candidate.mjs',
|
||||
find: ' const { result, url } = await ops.waitValidate(candidate);',
|
||||
find: " const { result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' });",
|
||||
replace: " const { result, url } = { result: 'green', url: 'skipped' }; // mutant: no validation",
|
||||
}],
|
||||
},
|
||||
@@ -8125,6 +8125,50 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: ' // mutant: the warm factory is deferred like a cold import\n',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'mutants-run-on-every-push',
|
||||
guard: 'node --test --test-name-pattern="#510" test/classify-changes.test.mjs',
|
||||
because: 'mutants by diff belong to the review candidate, the PR, the nightly run and the beta '
|
||||
+ 'candidate — an ordinary push must not spend 3×8 runner minutes on them (#510 AC1)',
|
||||
patches: [{
|
||||
file: 'scripts/classify-changes.mjs',
|
||||
find: " if (eventName === 'workflow_dispatch') return String(fullInput) === 'true' || String(mutantsInput) === 'true';\n return hasReleaseTrailer(headMessage);\n}",
|
||||
replace: " if (eventName === 'workflow_dispatch') return String(fullInput) === 'true' || String(mutantsInput) === 'true';\n return true; // mutant: every push\n}",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'review-starts-on-red-validate',
|
||||
guard: 'node --test test/validate-gate.test.mjs',
|
||||
because: 'a red dispatch run on the material must return the task without a review; treating '
|
||||
+ 'any completed run as green spends the review cycle on code CI already rejected (#510 AC2)',
|
||||
patches: [{
|
||||
file: 'scripts/validate-gate.mjs',
|
||||
find: " result: run.conclusion === 'success' ? 'green' : 'red',",
|
||||
replace: " result: 'green', // mutant: completed means green",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'review-trusts-push-run-without-mutants',
|
||||
guard: 'node --test test/validate-gate.test.mjs',
|
||||
because: 'a green push run on the same SHA holds no mutants and is not proof; the gate must '
|
||||
+ 'dispatch the mutant run instead of accepting it (#510 AC2)',
|
||||
patches: [{
|
||||
file: 'scripts/validate-gate.mjs',
|
||||
find: " return run?.event === 'workflow_dispatch';",
|
||||
replace: " return !!run; // mutant: any run counts",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'merge-waits-push-run-without-mutants',
|
||||
guard: 'node --test test/merge-candidate.test.mjs',
|
||||
because: 'the merged candidate is a new tree; the merge must dispatch the mutant run on it and '
|
||||
+ 'wait for that run, not for the push run that carries no mutants (#510 AC3)',
|
||||
patches: [{
|
||||
file: 'scripts/merge-candidate.mjs',
|
||||
find: " ops.dispatchValidate(branch);\n ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);\n const { result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' });",
|
||||
replace: " ops.log(`Validate на кандидате ${candidate.slice(0, 8)} — ждём`);\n const { result, url } = await ops.waitValidate(candidate); // mutant: push run, no dispatch",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'quota-counts-the-staged-upload-twice',
|
||||
guard: 'node scripts/backend-test-guard.mjs '
|
||||
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
#!/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);
|
||||
}
|
||||
Reference in New Issue
Block a user