Files
houseplan-card/scripts/mutation-nightly-reuse.mjs
T
Claudeandclaude[bot] 44ee23ee33 ci(mutants): skip the nightly registry on a proven tree; install only the shard's environment
#620. Mutants were ~70 % of CI machine time.

1. mutation-gate.yml: a green full run (aggregator verified all six shards)
   leaves a cache marker keyed by material tree + workflow SHA. A scheduled
   night with the same tree and workflow, marker not older than 7 days, skips
   the shards and writes "reused from run N" to the run summary. Red runs
   leave no marker, so the next night runs again and still files the issue
   (#472). Manual dispatch always runs the full registry. Decision is a pure
   function in scripts/mutation-nightly-reuse.mjs.
2. changed_mutants: the shard plan (already computed before setup, #518) now
   names the environment of its guards (plan-browser=/plan-python=, from
   scripts/mutation-environment.mjs). Python + backend deps only for shards
   with pytest guards, Chromium only for shards whose guards reach
   Playwright; pip wheels cached. Every shard still runs and reports, so the
   six mutant jobs of the review proof are unchanged.

Item 3 of the issue (smoke guards -> node --test) is out of scope here.

Issue: #620
User-Visible: no
2026-09-24 05:48:42 +00:00

133 lines
7.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// #620: ночной полный реестр не гоняется повторно на том же дереве.
//
// Что было. `mutation-gate.yml` каждую ночь прогонял все 800+ мутантов —
// ≈ 212 job-минут, — даже когда `dev` не менялся: 16–20.09 четыре ночи подряд
// ушли на один SHA. Доказательство прогона content-адресно уже с #549
// (evidence несёт tree), но им никто не пользовался.
//
// Как теперь. Зелёный полный прогон (все шесть шардов доказаны агрегатором)
// оставляет маркер: tree материала, SHA workflow, номер прогона, время. Ночь
// по расписанию читает самый свежий маркер своего tree и workflow и, если он
// действителен, не гоняет шарды — в сводке прогона стоит «reused from run N».
//
// Чего маркер не делает. Он не переносит красный: маркер пишется только после
// зелёного агрегатора, так что ночь после отказа гонит реестр заново и снова
// заводит issue (#472). Ручной dispatch — отладка гейта — всегда гонит полный
// реестр. Маркер старше MAX_REUSE_AGE_DAYS не принимается: tree фиксирует код,
// но не раннер (образ ubuntu, патч Python, кэш Chromium), и раз в неделю реестр
// обязан пройти на свежем окружении даже на неизменном дереве. SHA workflow в
// ключе — по той же причине: другой workflow (делитель шардов, шаги) — другое
// доказательство.
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { isMainModule } from './spawn-portable.mjs';
export const REUSE_MARKER_SCHEMA = 'houseplan-mutation-green/v1';
export const MAX_REUSE_AGE_DAYS = 7;
const DAY_MS = 24 * 60 * 60 * 1000;
// Часы раннеров расходятся на секунды; маркер «из будущего» дальше этого — подделка
// или сломанные часы, и принимать его нельзя.
const CLOCK_SKEW_MS = 10 * 60 * 1000;
const FULL_SHA = /^[0-9a-f]{40}$/;
const positiveInteger = (value) => /^[1-9]\d*$/.test(String(value ?? ''));
/** Маркер зелёного полного прогона. Бросает на неполной identity. */
export function reuseMarker(input) {
const marker = {
schema: REUSE_MARKER_SCHEMA,
tree: String(input.tree || ''),
sha: String(input.sha || ''),
workflowSha: String(input.workflowSha || ''),
runId: String(input.runId ?? ''),
runAttempt: String(input.runAttempt ?? ''),
event: String(input.event || ''),
provenAt: new Date(input.now ?? Date.now()).toISOString(),
};
if (!FULL_SHA.test(marker.tree) || !FULL_SHA.test(marker.sha) || !FULL_SHA.test(marker.workflowSha)
|| !positiveInteger(marker.runId) || !positiveInteger(marker.runAttempt) || !marker.event) {
throw new Error('invalid mutation reuse marker identity');
}
return marker;
}
/**
* Чистое решение: можно ли не гонять реестр этой ночью.
*
* @param {object} p
* @param {string} p.event github.event_name текущего прогона
* @param {string} p.tree tree зафиксированного материала
* @param {string} p.workflowSha github.workflow_sha текущего прогона
* @param {object|null} p.marker восстановленный маркер либо null
* @param {number} p.now мс эпохи
* @param {number} [p.maxAgeDays]
* @returns {{ reuse: boolean, reason: string, runId?: string }}
*/
export function decideNightlyReuse({ event, tree, workflowSha, marker, now, maxAgeDays = MAX_REUSE_AGE_DAYS }) {
const no = (reason) => ({ reuse: false, reason });
if (event !== 'schedule') return no('not a scheduled run — manual dispatch always runs the full registry');
if (!FULL_SHA.test(String(tree || ''))) return no('material tree is not a full SHA');
if (!marker || typeof marker !== 'object') return no('no green marker for this tree');
if (marker.schema !== REUSE_MARKER_SCHEMA) return no('marker schema is unknown');
if (marker.tree !== tree) return no('marker proves another tree');
if (!FULL_SHA.test(String(workflowSha || '')) || marker.workflowSha !== workflowSha) {
return no('marker was proved by another workflow revision');
}
if (!positiveInteger(marker.runId)) return no('marker names no run');
const provenAt = Date.parse(String(marker.provenAt || ''));
if (!Number.isFinite(provenAt)) return no('marker has no proof time');
if (provenAt - now > CLOCK_SKEW_MS) return no('marker is dated in the future');
if (now - provenAt > maxAgeDays * DAY_MS) return no(`marker is older than ${maxAgeDays} days`);
return { reuse: true, reason: `tree ${tree} already proved green`, runId: String(marker.runId) };
}
function readMarker(file) {
if (!file || !existsSync(file)) return null;
try {
return JSON.parse(readFileSync(file, 'utf8'));
} catch {
return null;
}
}
const arg = (argv, name) => argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
export function main(argv, { now = Date.now(), log = (line) => console.log(line), warn = (line) => console.error(line) } = {}) {
const writeTo = arg(argv, 'write-marker');
if (writeTo) {
const marker = reuseMarker({
tree: arg(argv, 'tree'), sha: arg(argv, 'sha'), workflowSha: arg(argv, 'workflow-sha'),
runId: arg(argv, 'run-id'), runAttempt: arg(argv, 'run-attempt'), event: arg(argv, 'event'), now,
});
mkdirSync(dirname(writeTo), { recursive: true });
writeFileSync(writeTo, `${JSON.stringify(marker, null, 2)}\n`);
warn(`маркер зелёного прогона: tree ${marker.tree}, run ${marker.runId}`);
return 0;
}
if (!argv.includes('--decide')) {
warn('usage: mutation-nightly-reuse.mjs --decide --event= --tree= --workflow-sha= --marker= [--summary=] [--run-url-base=]\n'
+ ' mutation-nightly-reuse.mjs --write-marker=<file> --tree= --sha= --workflow-sha= --run-id= --run-attempt= --event=');
return 2;
}
const decision = decideNightlyReuse({
event: arg(argv, 'event'), tree: arg(argv, 'tree'), workflowSha: arg(argv, 'workflow-sha'),
marker: readMarker(arg(argv, 'marker')), now,
});
// stdout — только строки key=value для $GITHUB_OUTPUT; человеку — stderr и сводка.
log(`reuse=${decision.reuse ? 'true' : 'false'}`);
if (decision.reuse) log(`reused_run=${decision.runId}`);
warn(decision.reuse ? `reused from run ${decision.runId}: ${decision.reason}` : `full run: ${decision.reason}`);
const summary = arg(argv, 'summary');
if (summary) {
const base = arg(argv, 'run-url-base');
const link = decision.reuse && base ? ` (${base}/${decision.runId})` : '';
appendFileSync(summary, decision.reuse
? `### Мутационный реестр: reused from run ${decision.runId}${link}\n\n`
+ `Дерево \`${arg(argv, 'tree')}\` уже доказано зелёным полным прогоном; шарды не запускались (#620).\n`
: `### Мутационный реестр: полный прогон\n\nПовторное использование отклонено: ${decision.reason}.\n`);
}
return 0;
}
if (isMainModule(import.meta.url)) process.exit(main(process.argv.slice(2)));