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
+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;
}
}