mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
#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
133 lines
7.8 KiB
JavaScript
133 lines
7.8 KiB
JavaScript
#!/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)));
|