ci: unify Validate proof across gates (#541)

Issue: #541
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-13 10:07:04 +03:00
parent 01ce4817c3
commit 9c269c302d
17 changed files with 991 additions and 101 deletions
+1 -2
View File
@@ -270,7 +270,7 @@ const BROWSER_PROTOCOL = ['demo/serve.mjs', 'demo/srv/demo.html', 'demo/bundle-f
'demo/editor-runtime-compat.mjs', 'demo/iso-runtime-compat.mjs', 'demo/guard/**'];
const WORKFLOW = ['.github/workflows/validate.yml'];
/** Протокол реюза: кто считает ключ, тот и вход (§5.1 protocol). */
const REUSE_PROTOCOL = ['scripts/gate-reuse.mjs', 'scripts/check-inputs.mjs'];
const REUSE_PROTOCOL = ['scripts/gate-reuse.mjs', 'scripts/check-inputs.mjs', 'scripts/ci-proof.mjs'];
/**
* Cross-runtime inputs, которые pytest читает динамически и которые поэтому
* нельзя вывести из статических import/string ссылок (#542).
@@ -286,7 +286,6 @@ const BACKEND_DYNAMIC_INPUTS = [
'demo/fixtures/large-house.mjs',
'demo/fixtures/visual-matrix.mjs',
];
export const CHECKS = {
preflight: {
// документация, провенанс, процесс — всегда запускается; реюза нет
+339
View File
@@ -0,0 +1,339 @@
#!/usr/bin/env node
// #541: один проверяемый контракт «зелёного Validate» для review, merge и
// release. Общий conclusion workflow недостаточен: лёгкий dispatch тоже green,
// а skipped job без доказанного content-addressed reuse ничего не доказывает.
import { inflateRawSync } from 'node:zlib';
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { isMainModule } from './spawn-portable.mjs';
export const CI_PROOF_SCHEMA = 'houseplan-ci-proof/v1';
export const CI_PROOF_ARTIFACT_PREFIX = 'ci-proof';
export const CI_PROOF_STATES = Object.freeze([
'green', 'missing', 'pending', 'cancelled', 'stale', 'failed',
]);
export const CI_PROOF_POLICIES = Object.freeze({
review: Object.freeze({ name: 'review', full: false, mutants: true }),
merge: Object.freeze({ name: 'merge', full: false, mutants: true }),
release: Object.freeze({ name: 'release', full: true, mutants: true }),
});
const JOB_RULES = Object.freeze({
preflight: [{ exact: 'Предполётные проверки: документация, провенанс, процесс', count: 1 }],
changes: [{ exact: 'Классификация изменённых файлов', count: 1 }],
reuse: [{ exact: 'Переиспользование: это дерево уже проверено', count: 1 }],
frontend: [{ exact: 'Фронтенд: типы, юниты, мутанты, синхрон бандла', count: 1 }],
integration: [
{ exact: 'HACS: валидация репозитория', count: 1 },
{ exact: 'Hassfest: манифест интеграции', count: 1 },
],
mutants: [{ prefix: 'Мутанты по диффу (', count: 6 }],
smoke: [
{ prefix: 'Смоки в браузере (шард ', count: 3 },
{ exact: 'Смоки: все шарды зелёные', count: 1 },
],
golden: [{ exact: 'Golden-кадры против принятых эталонов', count: 1 }],
performance_smoke: [{ exact: 'Перф-смок: бюджет времени кадра', count: 1 }],
backend: [{ exact: 'Бэкенд: pytest в Home Assistant', count: 1 }],
});
const asBool = (value) => value === true || String(value) === 'true';
const runIdOf = (run) => Number(run?.id ?? run?.databaseId ?? 0);
const runAttemptOf = (run) => Number(run?.run_attempt ?? run?.runAttempt ?? run?.attempt ?? 1);
const runShaOf = (run) => run?.head_sha ?? run?.headSha ?? '';
const runUrlOf = (run) => run?.html_url ?? run?.url ?? null;
const jobResult = (needs, id) => needs?.[id]?.result || 'missing';
const reuseSourceKey = (run, attempt) => `${Number(run)}:${Number(attempt)}`;
export function ciProofArtifactName(runId, attempt) {
return `${CI_PROOF_ARTIFACT_PREFIX}-${Number(runId)}-${Number(attempt)}`;
}
export function parseReuseMarker(text) {
const sha = String(text).match(/^SHA:\s*([0-9a-f]{40})\s*$/mi)?.[1] || null;
const runId = Number(String(text).match(/\/actions\/runs\/(\d+)/)?.[1] || 0) || null;
const attempt = Number(String(text).match(/^попытка:\s*(\d+)\s*$/mi)?.[1] || 0) || null;
if (!sha || !runId || !attempt)
throw new Error('reuse marker must contain a full SHA, an actions/runs/<id> URL and an attempt');
return { sourceSha: sha, sourceRun: runId, sourceAttempt: attempt };
}
export function requiredCheckIds({ request = {}, selection = {} } = {}) {
const ids = ['preflight', 'changes', 'reuse'];
if (asBool(selection.frontend)) ids.push('frontend');
if (asBool(selection.integration)) ids.push('integration');
if (asBool(request.mutants)) ids.push('mutants');
if (asBool(request.full)) ids.push('smoke', 'golden', 'performance_smoke');
if (asBool(selection.backend)) ids.push('backend');
return ids;
}
const reuseClaim = (outputs, id) => ({
key: outputs?.[`${id}_key`] || '',
sourceRun: Number(outputs?.[`${id}_source_run`] || 0) || null,
sourceAttempt: Number(outputs?.[`${id}_source_attempt`] || 0) || null,
sourceSha: outputs?.[`${id}_source_sha`] || null,
});
/** Build the immutable JSON uploaded by the final Validate job. */
export function buildCiProof({
candidateSha, candidateTree, runId, attempt, event, needs,
requestedFull = false, requestedMutants = false,
}) {
const changes = needs?.changes?.outputs || {};
const reuse = needs?.reuse?.outputs || {};
const request = {
full: asBool(requestedFull) || asBool(changes.heavy),
mutants: asBool(requestedMutants) || asBool(changes.mutants_requested),
};
const selection = {
frontend: asBool(changes.frontend),
backend: asBool(changes.backend),
integration: asBool(changes.integration),
};
const checks = {};
const executed = (id, result = jobResult(needs, id)) => {
checks[id] = { mode: 'executed', result };
};
const executedOrReused = (id, result = jobResult(needs, id)) => {
if (asBool(reuse[id])) {
checks[id] = { mode: 'reused', result: 'success', reuse: reuseClaim(reuse, id) };
} else {
executed(id, result);
}
};
executed('preflight');
executed('changes');
executed('reuse');
if (selection.frontend) executed('frontend');
if (selection.integration) {
checks.integration = {
mode: 'executed',
result: jobResult(needs, 'hacs') === 'success' && jobResult(needs, 'hassfest') === 'success'
? 'success' : `${jobResult(needs, 'hacs')}/${jobResult(needs, 'hassfest')}`,
};
}
if (request.mutants) executed('mutants', jobResult(needs, 'changed_mutants'));
if (request.full) {
executedOrReused('smoke', asBool(reuse.smoke)
? 'success'
: (jobResult(needs, 'smoke') === 'success' && jobResult(needs, 'smoke_done') === 'success'
? 'success' : `${jobResult(needs, 'smoke')}/${jobResult(needs, 'smoke_done')}`));
executedOrReused('golden');
executedOrReused('performance_smoke');
}
if (selection.backend) executedOrReused('backend');
const requiredChecks = requiredCheckIds({ request, selection });
return {
schema: CI_PROOF_SCHEMA,
candidate: { sha: candidateSha, tree: candidateTree },
run: { id: Number(runId), attempt: Number(attempt), workflow: 'validate.yml', event },
request,
selection,
requiredChecks,
executedChecks: requiredChecks.filter((id) => checks[id]?.mode === 'executed'),
reusedChecks: requiredChecks.filter((id) => checks[id]?.mode === 'reused'),
checks,
};
}
const sortedUnique = (values) => [...new Set(values)].sort();
const sameSet = (a, b) => JSON.stringify(sortedUnique(a)) === JSON.stringify(sortedUnique(b));
const jobsMatching = (jobs, rule) => (Array.isArray(jobs) ? jobs : []).filter((job) => (
rule.exact ? job?.name === rule.exact : String(job?.name || '').startsWith(rule.prefix)
));
function executedCheckIsGreen(id, jobs) {
return (JOB_RULES[id] || []).every((rule) => {
const matches = jobsMatching(jobs, rule);
return matches.length === rule.count && matches.every((job) => job.conclusion === 'success');
});
}
/**
* One state machine for all consumers. `reuseRuns` maps source run id to
* `{run,jobs}` fetched independently from the marker claim.
*/
export function evaluateCiProof({ run, proof, jobs = [], reuseRuns = new Map(), candidate = {}, policy }) {
const result = (status, note) => ({ status, note, url: runUrlOf(run) });
if (!run) return result('missing', 'Validate run is missing');
if (run.status !== 'completed') return result('pending', `Validate run ${runIdOf(run)} is ${run.status || 'pending'}`);
if (run.conclusion === 'cancelled') return result('cancelled', `Validate run ${runIdOf(run)} was cancelled`);
if (!proof) return result('missing', `Validate run ${runIdOf(run)} has no proof artifact`);
if (proof.schema !== CI_PROOF_SCHEMA) return result('stale', `unsupported proof schema ${proof.schema || 'missing'}`);
const expected = {
runId: runIdOf(run), attempt: runAttemptOf(run), sha: candidate.sha || runShaOf(run), tree: candidate.tree,
};
if (proof.run?.id !== expected.runId || proof.run?.attempt !== expected.attempt
|| proof.run?.workflow !== 'validate.yml' || proof.candidate?.sha !== expected.sha
|| (expected.tree && proof.candidate?.tree !== expected.tree)) {
return result('stale', 'proof does not belong to the candidate SHA/tree and run attempt');
}
if (runShaOf(run) && proof.candidate.sha !== runShaOf(run))
return result('stale', 'run head SHA differs from proof candidate');
if (run?.event && proof.run?.event !== run.event)
return result('stale', 'run event differs from proof event');
if (policy?.full && !asBool(proof.request?.full)) return result('stale', 'proof is light; full gates were not requested');
if (policy?.mutants && !asBool(proof.request?.mutants)) return result('stale', 'proof has no requested mutant jobs');
const derived = requiredCheckIds(proof);
if (!sameSet(derived, proof.requiredChecks || []))
return result('failed', 'proof required-check list is incomplete or inconsistent');
const claimedExecuted = derived.filter((id) => proof.checks?.[id]?.mode === 'executed');
const claimedReused = derived.filter((id) => proof.checks?.[id]?.mode === 'reused');
if (!sameSet(claimedExecuted, proof.executedChecks || [])
|| !sameSet(claimedReused, proof.reusedChecks || [])) {
return result('failed', 'proof executed/reused check lists are inconsistent');
}
if (run.conclusion !== 'success')
return result('failed', `Validate run ${runIdOf(run)} concluded ${run.conclusion || 'without success'}`);
for (const id of derived) {
const claim = proof.checks?.[id];
if (!claim || claim.result !== 'success') return result('failed', `${id}: proof result is ${claim?.result || 'missing'}`);
if (claim.mode === 'executed') {
if (!executedCheckIsGreen(id, jobs)) return result('failed', `${id}: claimed execution is absent, incomplete or not green`);
continue;
}
if (claim.mode !== 'reused' || !['smoke', 'golden', 'performance_smoke', 'backend'].includes(id))
return result('failed', `${id}: unsupported proof mode ${claim.mode || 'missing'}`);
const reuse = claim.reuse || {};
if (!/^[0-9a-f]{64}$/.test(reuse.key || '') || !/^[0-9a-f]{40}$/.test(reuse.sourceSha || '')
|| !Number.isInteger(reuse.sourceRun) || reuse.sourceRun <= 0
|| !Number.isInteger(reuse.sourceAttempt) || reuse.sourceAttempt <= 0) {
return result('failed', `${id}: content-addressed reuse evidence is incomplete`);
}
const sourceKey = reuseSourceKey(reuse.sourceRun, reuse.sourceAttempt);
const source = reuseRuns instanceof Map ? reuseRuns.get(sourceKey) : reuseRuns?.[sourceKey];
if (!source || runIdOf(source.run) !== reuse.sourceRun || runAttemptOf(source.run) !== reuse.sourceAttempt
|| runShaOf(source.run) !== reuse.sourceSha
|| !executedCheckIsGreen(id, source.jobs)) {
return result('failed', `${id}: source run does not verify the reused successful job`);
}
}
return result('green', `${policy?.name || 'consumer'} proof is complete`);
}
/** Newest relevant proof wins; cancelled and policy-inadequate stale runs do not. */
export function selectCiProofVerdict(evaluations) {
for (const item of evaluations || []) {
if (item?.status === 'cancelled' || item?.status === 'stale') continue;
return item;
}
return { status: 'missing', note: 'no run carries a proof for the requested policy', url: null };
}
export function readCiProofArtifact(bytes) {
const signature = 0x06054b50;
let eocd = -1;
for (let at = bytes.length - 22; at >= Math.max(0, bytes.length - 65557); at -= 1) {
if (bytes.readUInt32LE(at) === signature) { eocd = at; break; }
}
if (eocd < 0) throw new Error('proof artifact is not a ZIP archive');
const count = bytes.readUInt16LE(eocd + 10);
let cursor = bytes.readUInt32LE(eocd + 16);
for (let index = 0; index < count; index += 1) {
if (bytes.readUInt32LE(cursor) !== 0x02014b50) throw new Error('proof artifact central directory is malformed');
const method = bytes.readUInt16LE(cursor + 10);
const compressedSize = bytes.readUInt32LE(cursor + 20);
const nameLength = bytes.readUInt16LE(cursor + 28);
const extraLength = bytes.readUInt16LE(cursor + 30);
const commentLength = bytes.readUInt16LE(cursor + 32);
const local = bytes.readUInt32LE(cursor + 42);
const name = bytes.subarray(cursor + 46, cursor + 46 + nameLength).toString('utf8');
cursor += 46 + nameLength + extraLength + commentLength;
if (!/(^|\/)proof[.]json$/.test(name)) continue;
if (bytes.readUInt32LE(local) !== 0x04034b50) throw new Error('proof artifact local header is malformed');
const localName = bytes.readUInt16LE(local + 26);
const localExtra = bytes.readUInt16LE(local + 28);
const start = local + 30 + localName + localExtra;
const compressed = bytes.subarray(start, start + compressedSize);
const body = method === 0 ? compressed : method === 8 ? inflateRawSync(compressed) : null;
if (!body) throw new Error(`unsupported proof artifact compression ${method}`);
return JSON.parse(body.toString('utf8'));
}
throw new Error('proof.json is missing from artifact');
}
const apiHeaders = (token) => ({
Accept: 'application/vnd.github+json', Authorization: `Bearer ${token}`,
'User-Agent': 'houseplan-ci-proof', 'X-GitHub-Api-Version': '2022-11-28',
});
async function githubJson(url, token, fetchImpl) {
const response = await fetchImpl(url, { headers: apiHeaders(token) });
if (!response.ok) throw new Error(`GitHub API ${response.status}: ${await response.text()}`);
return response.json();
}
export async function githubCandidateTree({ repo, sha, token, fetchImpl = fetch }) {
const row = await githubJson(`https://api.github.com/repos/${repo}/git/commits/${sha}`, token, fetchImpl);
return row?.tree?.sha || null;
}
export async function loadGithubProofContext({ repo, run, token, fetchImpl = fetch }) {
const runId = runIdOf(run);
const attempt = runAttemptOf(run);
const name = ciProofArtifactName(runId, attempt);
const list = await githubJson(
`https://api.github.com/repos/${repo}/actions/runs/${runId}/artifacts?name=${encodeURIComponent(name)}`,
token, fetchImpl,
);
const artifact = (list?.artifacts || []).find((item) => item.name === name && !item.expired);
let proof = null;
if (artifact) {
const response = await fetchImpl(artifact.archive_download_url, { headers: apiHeaders(token) });
if (!response.ok) throw new Error(`proof artifact download ${response.status}: ${await response.text()}`);
proof = readCiProofArtifact(Buffer.from(await response.arrayBuffer()));
}
const jobsBody = await githubJson(
`https://api.github.com/repos/${repo}/actions/runs/${runId}/jobs?per_page=100`, token, fetchImpl,
);
const jobs = jobsBody?.jobs || [];
const reuseRuns = new Map();
for (const id of proof?.reusedChecks || []) {
const sourceId = proof?.checks?.[id]?.reuse?.sourceRun;
const sourceAttempt = proof?.checks?.[id]?.reuse?.sourceAttempt;
const sourceKey = reuseSourceKey(sourceId, sourceAttempt);
if (!sourceId || !sourceAttempt || reuseRuns.has(sourceKey)) continue;
const sourceRun = await githubJson(
`https://api.github.com/repos/${repo}/actions/runs/${sourceId}/attempts/${sourceAttempt}`, token, fetchImpl,
);
const sourceJobs = await githubJson(
`https://api.github.com/repos/${repo}/actions/runs/${sourceId}/attempts/${sourceAttempt}/jobs?per_page=100`,
token, fetchImpl,
);
reuseRuns.set(sourceKey, { run: sourceRun, jobs: sourceJobs?.jobs || [] });
}
return { proof, jobs, reuseRuns };
}
if (isMainModule(import.meta.url)) {
const value = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
const marker = value('marker');
const emit = value('emit');
if (marker) {
const parsed = parseReuseMarker(readFileSync(resolve(marker), 'utf8'));
const output = `source_run=${parsed.sourceRun}\nsource_attempt=${parsed.sourceAttempt}\nsource_sha=${parsed.sourceSha}\n`;
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, output);
process.stdout.write(output);
} else if (emit) {
const proof = buildCiProof({
candidateSha: process.env.CANDIDATE_SHA,
candidateTree: process.env.CANDIDATE_TREE,
runId: process.env.CI_RUN_ID,
attempt: process.env.CI_RUN_ATTEMPT,
event: process.env.CI_EVENT,
needs: JSON.parse(process.env.NEEDS_JSON || '{}'),
requestedFull: process.env.REQUEST_FULL,
requestedMutants: process.env.REQUEST_MUTANTS,
});
const target = resolve(emit);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, `${JSON.stringify(proof, null, 2)}\n`);
console.log(`CI proof: ${target} (${proof.requiredChecks.join(', ')})`);
} else {
console.error('usage: ci-proof.mjs --emit=<proof.json> | --marker=<.reuse-marker>');
process.exitCode = 2;
}
}
+30 -10
View File
@@ -22,6 +22,9 @@
import { spawnSync } from 'node:child_process';
import { appendFileSync } from 'node:fs';
import { isMainModule } from './spawn-portable.mjs';
import {
CI_PROOF_POLICIES, evaluateCiProof, githubCandidateTree, loadGithubProofContext,
} from './ci-proof.mjs';
export const MAX_ATTEMPTS = 3;
export const VALIDATE_APPEAR_MS = 3 * 60 * 1000;
@@ -36,7 +39,7 @@ export const VALIDATE_TOTAL_MS = 45 * 60 * 1000;
* @param {boolean} s.devMoved dev не равен базе материала
* @param {boolean} s.conflict ребейз на dev упал
* @param {boolean} s.patchIdEqual дифф после ребейза совпадает с проверенным
* @param {'green'|'red'|'missing'|null} s.validate результат Validate на кандидате
* @param {'green'|'failed'|'missing'|'pending'|'cancelled'|'stale'|null} s.validate результат общего CI proof
* @param {boolean} s.leaseRejected push в dev отклонён: dev двинулся снова
* @param {number} s.attempt номер попытки, с 1
*/
@@ -49,8 +52,8 @@ export function decideMerge(s) {
}
if (!s.patchIdEqual) return { action: 'rereview', to: 'S7-code-review' };
if (s.validate === null || s.validate === undefined) return { action: 'validate' };
if (s.validate === 'missing') return { action: 'validation-missing', to: 'S6-in-progress' };
if (s.validate === 'red') return { action: 'validation-red', to: 'S6-in-progress' };
if (['missing', 'pending', 'cancelled', 'stale'].includes(s.validate)) return { action: 'validation-missing', to: 'S6-in-progress' };
if (s.validate === 'failed') return { action: 'validation-red', to: 'S6-in-progress' };
if (s.leaseRejected) {
if ((s.attempt ?? 1) >= (s.maxAttempts ?? MAX_ATTEMPTS)) return { action: 'give-up', to: 'S6-in-progress' };
return { action: 'retry' };
@@ -101,7 +104,12 @@ const sh = (cmd, args, opts = {}) => {
return { status: r.status ?? 1, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim() };
};
export function realOps({ repo, token, workflow = 'validate.yml', sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now, exec = sh }) {
export function realOps({
repo, token, workflow = 'validate.yml', sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
now = Date.now, exec = sh,
candidateTree = (sha) => githubCandidateTree({ repo, sha, token }),
proofContext = (run) => loadGithubProofContext({ repo, run, token }),
}) {
const pushUrl = `https://x-access-token:${token}@github.com/${repo}`;
const git = (...args) => exec('git', args);
const must = (r, what) => { if (r.status !== 0) throw new Error(`${what}: ${r.stderr || r.stdout}`); return r.stdout; };
@@ -140,22 +148,34 @@ export function realOps({ repo, token, workflow = 'validate.yml', sleep = (ms) =
waitValidate: async (sha, { event = 'workflow_dispatch' } = {}) => {
const started = now();
let runId = null;
const ignored = new Set();
const tree = await candidateTree(sha);
while (now() - started < VALIDATE_TOTAL_MS) {
const r = exec('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--commit', sha, '--json', 'databaseId,status,conclusion,url,event', '--limit', '10']);
const r = exec('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--commit', sha, '--json', 'databaseId,status,conclusion,url,event,headSha,attempt,startedAt,createdAt', '--limit', '10']);
const all = r.status === 0 && r.stdout ? JSON.parse(r.stdout) : [];
// Отменённый прогон ничего не доказывает (#511): его заменил следующий
// dispatch на той же ветке — ждём его, а не красим кандидата.
const runs = all.filter((x) => (!event || x.event === event) && x.conclusion !== 'cancelled');
const runs = all.filter((x) => (!event || x.event === event) && !ignored.has(x.databaseId));
const run = runs.find((x) => x.databaseId === runId) || runs[0];
if (run) {
runId = run.databaseId;
if (run.status === 'completed') return { result: run.conclusion === 'success' ? 'green' : 'red', url: run.url };
if (run.status === 'completed') {
let context;
try { context = await proofContext(run); }
catch { context = { proof: null, jobs: [], reuseRuns: new Map() }; }
const verdict = evaluateCiProof({
run, ...context, candidate: { sha, tree }, policy: CI_PROOF_POLICIES.merge,
});
if (verdict.status === 'green' || verdict.status === 'failed')
return { result: verdict.status, url: verdict.url, note: verdict.note };
ignored.add(run.databaseId);
runId = null;
continue;
}
} else if (now() - started > VALIDATE_APPEAR_MS) {
return { result: 'missing', url: null };
}
await sleep(20_000);
}
return { result: 'red', url: runId ? `run ${runId} (timeout)` : null };
return { result: 'failed', url: runId ? `run ${runId} (timeout)` : null };
},
comment: (issue, body) => {
const r = spawnSync('gh', ['issue', 'comment', String(issue), '--repo', repo, '--body-file', '-'], { input: body, encoding: 'utf8' });
+47 -14
View File
@@ -8675,6 +8675,39 @@ const MUTANT_DEFINITIONS = [
replace: " return relevant.sort((a, b) => stamp(a) - stamp(b) || Number(a.id || 0) - Number(b.id || 0))[0] || null; // mutant: oldest",
}],
},
{
id: 'release-proof-accepts-light-run',
guard: 'node --test test/ci-proof.test.mjs test/release-gate.test.mjs',
because: 'a later light workflow_dispatch must not hide an older full failure and release assets; '
+ 'release accepts only a proof that requested the heavy matrix (#541)',
patches: [{
file: 'scripts/ci-proof.mjs',
find: " if (policy?.full && !asBool(proof.request?.full)) return result('stale', 'proof is light; full gates were not requested');",
replace: " if (false && policy?.full && !asBool(proof.request?.full)) return result('stale', 'mutant');",
}],
},
{
id: 'ci-proof-ignores-run-attempt',
guard: 'node --test test/ci-proof.test.mjs',
because: 'rerunning the same Actions run changes its attempt and jobs; an artifact from another '
+ 'attempt cannot vouch for the current result (#541)',
patches: [{
file: 'scripts/ci-proof.mjs',
find: ' if (proof.run?.id !== expected.runId || proof.run?.attempt !== expected.attempt\n',
replace: ' if (proof.run?.id !== expected.runId || false && proof.run?.attempt !== expected.attempt\n',
}],
},
{
id: 'ci-proof-trusts-reuse-without-source-job',
guard: 'node --test test/ci-proof.test.mjs',
because: 'a cache-hit bit and key are not proof; lawful reuse also needs the source SHA/run and '
+ 'the independently fetched successful source job (#541)',
patches: [{
file: 'scripts/ci-proof.mjs',
find: " if (!source || runIdOf(source.run) !== reuse.sourceRun || runAttemptOf(source.run) !== reuse.sourceAttempt\n || runShaOf(source.run) !== reuse.sourceSha\n || !executedCheckIsGreen(id, source.jobs)) {",
replace: " if (false && (!source || runIdOf(source.run) !== reuse.sourceRun || runAttemptOf(source.run) !== reuse.sourceAttempt\n || runShaOf(source.run) !== reuse.sourceSha\n || !executedCheckIsGreen(id, source.jobs))) {",
}],
},
{
id: 'summary-first-paint-shows-unavailable',
guard: 'node demo/smoke_summary_first_paint.mjs',
@@ -8792,13 +8825,13 @@ const MUTANT_DEFINITIONS = [
},
{
id: 'review-starts-on-red-validate',
guard: 'node --test test/validate-gate.test.mjs',
guard: 'node --test test/ci-proof.test.mjs 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: " if (run.conclusion !== 'success') return { result: 'red', url: run.url, note: `dispatch-прогон завершился: ${run.conclusion}` };",
replace: " // mutant: completed means green — a red dispatch falls through to the job check",
file: 'scripts/ci-proof.mjs',
find: " if (run.conclusion !== 'success')\n return result('failed', `Validate run ${runIdOf(run)} concluded ${run.conclusion || 'without success'}`);",
replace: " if (false && run.conclusion !== 'success')\n return result('failed', 'mutant'); // mutant: completed means green",
}],
},
{
@@ -8847,13 +8880,13 @@ const MUTANT_DEFINITIONS = [
},
{
id: 'review-returns-task-on-cancelled-dispatch',
guard: 'node --test test/validate-gate.test.mjs',
guard: 'node --test test/ci-proof.test.mjs test/validate-gate.test.mjs',
because: 'a dispatch cancelled by its replacement in the same concurrency group proves nothing; '
+ 'reading it as red sends the task back to S6 for no reason (#510 review r1 M1, #511)',
patches: [{
file: 'scripts/validate-gate.mjs',
find: " if (run.conclusion === 'cancelled') {",
replace: " if (false) { // mutant: cancelled counts as red",
file: 'scripts/ci-proof.mjs',
find: " if (run.conclusion === 'cancelled') return result('cancelled', `Validate run ${runIdOf(run)} was cancelled`);",
replace: " if (false && run.conclusion === 'cancelled') return result('cancelled', 'mutant');",
}],
},
{
@@ -8868,14 +8901,14 @@ const MUTANT_DEFINITIONS = [
}],
},
{
id: 'merge-trusts-cancelled-dispatch',
id: 'merge-trusts-success-without-proof',
guard: 'node --test test/merge-candidate.test.mjs',
because: 'the real waitValidate must skip a dispatch cancelled by its replacement; reading it as red '
+ 'fails the merge candidate for nothing (#510 review r2 M1, #511)',
because: 'a workflow conclusion does not prove that mutant jobs ran; merge must require the shared '
+ 'artifact instead of accepting a successful dispatch on the candidate SHA (#541)',
patches: [{
file: 'scripts/merge-candidate.mjs',
find: " const runs = all.filter((x) => (!event || x.event === event) && x.conclusion !== 'cancelled');",
replace: " const runs = all.filter((x) => (!event || x.event === event)); // mutant: cancelled is red",
file: 'scripts/ci-proof.mjs',
find: " if (!proof) return result('missing', `Validate run ${runIdOf(run)} has no proof artifact`);",
replace: " if (!proof) return result('green', 'mutant: conclusion alone');",
}],
},
{
+49 -9
View File
@@ -3,6 +3,10 @@
// performance workflow.
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
CI_PROOF_POLICIES, evaluateCiProof, githubCandidateTree,
loadGithubProofContext, selectCiProofVerdict,
} from './ci-proof.mjs';
/**
* The verdict is the LATEST run that was not cancelled (#511). A cancelled run
@@ -26,6 +30,39 @@ export function classifyValidateRuns(runs) {
return latest.conclusion === 'success' ? 'success' : 'fail';
}
const newestFirst = (runs) => [...(Array.isArray(runs) ? runs : [])].sort((a, b) => {
const stamp = (run) => Date.parse(run?.run_started_at || run?.startedAt || run?.created_at || run?.createdAt || 0) || 0;
return stamp(b) - stamp(a) || Number(b?.id || b?.databaseId || 0) - Number(a?.id || a?.databaseId || 0);
});
/** #541: proof-aware verdict shared with review and merge. */
export async function classifyValidateProofs({
runs, repo, sha, tree, token, fetchImpl = fetch,
loadContext = (run) => loadGithubProofContext({ repo, run, token, fetchImpl }),
}) {
const evaluations = [];
for (const run of newestFirst(runs)) {
if (run?.status !== 'completed' || run?.conclusion === 'cancelled') {
evaluations.push(evaluateCiProof({ run, candidate: { sha, tree }, policy: CI_PROOF_POLICIES.release }));
} else {
try {
const context = await loadContext(run);
evaluations.push(evaluateCiProof({
run, ...context, candidate: { sha, tree }, policy: CI_PROOF_POLICIES.release,
}));
} catch (error) {
evaluations.push({
status: 'missing', url: run.html_url || run.url || null,
note: `proof could not be loaded: ${error instanceof Error ? error.message : String(error)}`,
});
}
}
const current = evaluations.at(-1);
if (current.status !== 'cancelled' && current.status !== 'stale') break;
}
return selectCiProofVerdict(evaluations);
}
export const workflowRunsUrl = ({ repo, workflow, sha }) => (
`https://api.github.com/repos/${repo}/actions/workflows/${encodeURIComponent(workflow)}`
+ `/runs?head_sha=${encodeURIComponent(sha)}&per_page=100`
@@ -39,6 +76,8 @@ export async function waitForGreenWorkflow({
if (!repo || !sha || !token || !workflow) throw new Error('repo, sha, token and workflow are required');
const deadline = Date.now() + timeoutMs;
const url = workflowRunsUrl({ repo, workflow, sha });
const proofRequired = workflow === 'validate.yml';
const tree = proofRequired ? await githubCandidateTree({ repo, sha, token }) : null;
while (true) {
const response = await fetch(url, {
headers: {
@@ -51,21 +90,22 @@ export async function waitForGreenWorkflow({
if (!response.ok) throw new Error(`GitHub Actions API ${response.status}: ${await response.text()}`);
const body = await response.json();
const runs = Array.isArray(body?.workflow_runs) ? body.workflow_runs : [];
const state = classifyValidateRuns(runs);
const latest = latestRelevantRun(runs);
if (state === 'fail') {
throw new Error(`${label} is not green for ${sha}: latest run ${JSON.stringify({
conclusion: latest.conclusion, url: latest.html_url,
})}`);
const verdict = proofRequired
? await classifyValidateProofs({ runs, repo, sha, tree, token })
: { status: classifyValidateRuns(runs) === 'success' ? 'green'
: classifyValidateRuns(runs) === 'fail' ? 'failed' : 'pending', url: latest?.html_url, note: '' };
if (verdict.status === 'failed') {
throw new Error(`${label} is not green for ${sha}: ${verdict.note}${verdict.url ? ` (${verdict.url})` : ''}`);
}
if (state === 'success') {
console.log(`${label} is green for ${sha}: latest run ${latest.html_url || latest.id} (${runs.length} run(s) on the SHA)`);
if (verdict.status === 'green') {
console.log(`${label} proof is green for ${sha}: ${verdict.url || latest?.html_url || latest?.id} (${runs.length} run(s) on the SHA)`);
return;
}
if (Date.now() >= deadline) throw new Error(`No completed green ${label} for ${sha} within the deadline`);
if (Date.now() >= deadline) throw new Error(`No complete ${label} proof for ${sha} within the deadline: ${verdict.status} (${verdict.note})`);
const running = runs.filter((run) => run?.status !== 'completed').length;
console.log(runs.length
? `waiting: ${running} ${label} run(s) still going`
? `waiting: ${label} proof is ${verdict.status}; ${running} run(s) still going (${verdict.note})`
: `waiting: no ${label} run for ${sha} yet`);
await sleep(30_000);
}
+9 -11
View File
@@ -12,7 +12,7 @@ import { spawnSync } from 'node:child_process';
import { createInterface } from 'node:readline/promises';
import { stdin, stdout } from 'node:process';
import { assertReleaseContract } from './release-contract.mjs';
import { classifyValidateRuns } from './release-gate.mjs';
import { classifyValidateProofs } from './release-gate.mjs';
import { assertBundleManifest } from './bundle-tree.mjs';
import { SUMS_FILE, compareSums, formatSums, parseSums, sumsOfDirectory } from './release-assets.mjs';
@@ -392,18 +392,16 @@ if (invokedDirectly) {
}
};
const assertGreenValidate = (sha) => {
const assertGreenValidate = async (sha) => {
const runs = ghJson([
'run', 'list', '--repo', repo, '--workflow', 'validate.yml', '--commit', sha,
'--limit', '100', '--json', 'databaseId,status,conclusion,url,headSha',
'--limit', '100', '--json', 'databaseId,status,conclusion,url,headSha,event,attempt,startedAt,createdAt',
]);
const state = classifyValidateRuns(runs);
if (state !== 'success') {
throw new Error(
state === 'wait'
? `Exact-SHA Validate has not completed successfully for ${sha}`
: `Exact-SHA Validate contains a failed/cancelled run for ${sha}`,
);
const tree = run('git', ['rev-parse', `${sha}^{tree}`]).stdout;
const token = run('gh', ['auth', 'token']).stdout;
const verdict = await classifyValidateProofs({ runs, repo, sha, tree, token });
if (verdict.status !== 'green') {
throw new Error(`Exact-SHA Validate proof is ${verdict.status} for ${sha}: ${verdict.note}`);
}
return runs;
};
@@ -487,7 +485,7 @@ if (invokedDirectly) {
if (sha !== remoteBranch) throw new Error(`HEAD ${sha} is not synchronized with origin/${branch} ${remoteBranch}`);
const bundleSnapshot = assertBundleSnapshots(sha);
const bundleSha256 = bundleSnapshot.entrySha256;
const validateRuns = assertGreenValidate(sha);
const validateRuns = await assertGreenValidate(sha);
validateIssues();
const existingTag = remoteTag();
if (existingTag.exists && existingTag.commit !== sha)
+23 -18
View File
@@ -9,7 +9,7 @@
*
* node scripts/validate-gate.mjs --repo=<owner/repo> --ref=<ветка> --sha=<sha> [--workflow=validate.yml]
*
* Печатает `result=green|red|missing` и `url=…` (и в $GITHUB_OUTPUT, если он
* Печатает `result=green|failed|missing` и `url=…` (и в $GITHUB_OUTPUT, если он
* задан); код выхода 0 только при green. Логика — чистая функция `validateGate`
* поверх инъектируемых `ops`, чтобы тесты и мутанты гоняли её без gh.
*/
@@ -18,6 +18,9 @@ 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';
import {
CI_PROOF_POLICIES, evaluateCiProof, githubCandidateTree, loadGithubProofContext,
} from './ci-proof.mjs';
export const POLL_MS = 20_000;
/**
@@ -54,12 +57,13 @@ export function provesMutants(jobs) {
* @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}>}
* @param {object} p.ops GitHub run/proof operations plus dispatch, sleep and clock.
* @returns {Promise<{result:'green'|'failed'|'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, которые ничего не доказывают: отменённые и зелёные без мутантов
const candidateTree = await ops.candidateTree(sha);
const ignored = new Set(); // завершённые dispatch без применимого proof
let tracked = null;
let dispatchedAt = null;
let attempts = 0;
@@ -69,17 +73,13 @@ export async function validateGate({ ref, sha, ops, appearMs = VALIDATE_APPEAR_M
if (run) {
tracked = run.databaseId;
if (run.status === 'completed') {
if (run.conclusion === 'cancelled') {
// Отменённый прогон ничего не доказывает (#511, ревью r1 M1): его
// заменил другой dispatch в той же concurrency-группе — ждём его,
// а если замены нет, запускаем свой.
ignored.add(run.databaseId);
tracked = null;
continue;
}
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) — не доказательство
const context = await ops.proof(run);
const verdict = evaluateCiProof({
run, ...context, candidate: { sha, tree: candidateTree }, policy: CI_PROOF_POLICIES.review,
});
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 };
// cancelled, light, stale или legacy run без proof — не доказательство.
ignored.add(run.databaseId);
tracked = null;
continue;
@@ -112,13 +112,13 @@ export async function validateGate({ ref, sha, ops, appearMs = VALIDATE_APPEAR_M
}
await ops.sleep(pollMs);
}
return { result: 'red', url: null, note: 'Validate с мутантами не завершился за 45 минут' };
return { result: 'failed', 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';
export function realOps({ repo, workflow = 'validate.yml', token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN }) {
const fields = 'databaseId,status,conclusion,url,event,headSha,attempt,startedAt,createdAt';
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'])),
@@ -126,6 +126,11 @@ export function realOps({ repo, workflow = 'validate.yml' }) {
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 })) : [];
},
candidateTree: (sha) => githubCandidateTree({ repo, sha, token }),
proof: async (run) => {
try { return await loadGithubProofContext({ repo, run, token }); }
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']);