Files
houseplan-card/scripts/ci-proof.mjs
T
Claudeandclaude[bot] 88c4e4e9ae infra(process): bundle and review index change only on the way to dev (#657)
Решения владельца: 1б — индекс ревью не пересобирается в ветке задачи,
только коммитами, идущими в dev; 2б — бандл меняет только кандидат
беты/релиза, стенд dev берёт его из артефакта Validate.

- scripts/bundle-policy.mjs: коммит, трогающий dist/** или
  custom_components/houseplan/frontend/**, обязан нести Release:
  (хук commit-msg и история в CI через validate-commit-provenance;
  коммиты с датой автора до 2026-09-27 не судятся); --verify судит
  целостность свежей сборки всегда, побайтовую сверку с закоммиченной
  копией — только на коммите, меняющем бандл, или кандидате; --clean.
- release-prerelease: публикация отказывает, если отпечаток исходников
  в закоммиченном манифесте не равен отпечатку дерева (хотфикс поверх
  кандидата без пересборки).
- bundle-sync: по умолчанию только demo/srv/assets; --release
  (npm run bundle:release) — ещё и custom_components.
- rebase-on-dev: конфликт в бандле берёт копию dev, без пересборки
  и amend.
- validate.yml: job dev_build публикует card-bundle головы dev в
  сиротскую ветку dev-build (scripts/dev-build.mjs); стенд накладывает
  её demo/stand/update-dev-bundle.sh.
- _process.yml: индекс ревью больше не пересобирается при приведении
  к dev и при публикации документа в ветку задачи.
- golden-wsl-artifact/golden-container: сборка перед съёмкой не
  считается правкой источника, после — bundle:clean.
- test/bundle-tree-committed: судит закоммиченный снимок, не диск.
- 11 мутантов в реестре; PROCESS/AGENTS/DEVELOPMENT/AUTHOR/REVIEWER.

Issue: #657
User-Visible: no
2026-09-26 07:28:23 +00:00

589 lines
31 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
// #541: один проверяемый контракт «зелёного Validate» для review, merge и
// release. Общий conclusion workflow недостаточен: лёгкий dispatch тоже green,
// а skipped job без доказанного content-addressed reuse ничего не доказывает.
import { inflateRawSync } from 'node:zlib';
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { isMainModule } from './spawn-portable.mjs';
import { BASELINE_OVERLAY, REUSE_JOBS, globToRegExp } from './check-inputs.mjs';
import { reuseKey } from './gate-reuse.mjs';
import { jobInstanceNames, staticNamePrefix, validateJobs } from './workflow-jobs.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',
]);
// Мутанты по диффу — доказательство для ревью и слияния (#510): без всех
// исполненных mutant-jobs (сколько их — говорит матрица validate.yml, #622)
// ни то ни другое не разрешается. Релиз их не
// требует (#601): к кандидату беты каждая задача прогнана ими на ревью и на
// слитом кандидате, а `Release:` мутантов больше не запрашивает — политика
// с `mutants: true` объявляла бы каждый кандидат беты `stale`.
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: false }),
});
// #622: какие job `validate.yml` доказывают проверку. `name` — контракт имени
// (у матричной job — неизменная часть до `${{`); его сверяет с YAML
// test/workflow-jobs.test.mjs, а здесь — resolveJobRules на каждом вызове
// evaluateCiProof. Сколько экземпляров и как точно они названы, читается из
// самого validate.yml (`strategy.matrix`): константа `count: 6` жила отдельно
// от матрицы и при смене шардов молча давала «claimed execution is absent».
export const JOB_RULES = Object.freeze({
preflight: [{ job: 'preflight', name: 'Предполёт: документация, провенанс, процесс' }],
changes: [{ job: 'changes', name: 'Классификация изменённых файлов' }],
reuse: [{ job: 'reuse', name: 'Переиспользование: это дерево уже проверено' }],
frontend: [{ job: 'frontend', name: 'Фронтенд: типы, юниты, мутанты, синхрон бандла' }],
integration: [
{ job: 'hacs', name: 'HACS: валидация репозитория' },
{ job: 'hassfest', name: 'Hassfest: манифест интеграции' },
],
mutants: [{ job: 'changed_mutants', name: 'Мутанты по диффу (' }],
smoke: [
{ job: 'smoke', name: 'Смоки в браузере (шард ' },
{ job: 'smoke_done', name: 'Смоки: все шарды зелёные' },
],
golden: [{ job: 'golden', name: 'Golden-кадры против принятых эталонов' }],
performance_smoke: [{ job: 'performance_smoke', name: 'Перф-смок: бюджет времени кадра' }],
geometry_parity: [{ job: 'geometry_parity', name: 'Геометрия: TS/Python parity исполнена' }],
backend: [{ job: 'backend', name: 'Бэкенд: pytest в Home Assistant' }],
});
/**
* Job validate.yml, чьё имя ни одно правило не читает. Записаны с именем,
* чтобы контрактный тест видел ВСЕ job в обе стороны (#622 AC1): новая job
* или переименование любой — решение, а не тихое расхождение.
*/
export const UNCONSUMED_JOBS = Object.freeze({
proof: 'Доказательство выполненных проверок',
// #657: публикация бандла для стенда — не проверка кода и не вход proof.
dev_build: 'Бандл головы dev для стенда',
});
/** Общий префикс имён mutant-jobs — единственный источник для validate-gate. */
export const MUTANT_JOB_PREFIX = JOB_RULES.mutants[0].name;
/**
* Правила с точными именами экземпляров из validate.yml. Расхождение
* контракта с файлом — ошибка с названной job, а не пустое совпадение:
* иначе ревью, слияние и релиз узнали бы о переименовании как о
* «claimed execution is absent» (#622).
*/
export function resolveJobRules(workflowJobs = validateJobs()) {
return Object.fromEntries(Object.entries(JOB_RULES).map(([id, rules]) => [id, rules.map((rule) => {
const job = workflowJobs.get(rule.job);
if (!job) throw new Error(`${id}: validate.yml has no job ${rule.job}`);
const actual = job.matrix ? staticNamePrefix(job.name) : job.name;
if (actual !== rule.name) {
throw new Error(`${id}: validate.yml job ${rule.job} is named ${JSON.stringify(job.name)}, ci-proof expects ${JSON.stringify(rule.name)}${job.matrix ? ' as its prefix' : ''}`);
}
return { job: rule.job, names: jobInstanceNames(job) };
})]));
}
/**
* Все расхождения контракта имён с validate.yml — в обе стороны (#622 AC1):
* правило без job, job с другим именем, job без записи. Пустой список —
* контракт цел. Рантайм сверяет только читаемые job (resolveJobRules), тест —
* все.
*/
export function jobContractProblems(workflowJobs = validateJobs()) {
const problems = [];
const declared = new Map();
const declare = (job, name, owner) => {
if (declared.has(job)) problems.push(`${job}: declared twice (${declared.get(job).owner}, ${owner})`);
declared.set(job, { name, owner });
};
for (const [id, rules] of Object.entries(JOB_RULES)) for (const rule of rules) declare(rule.job, rule.name, `JOB_RULES.${id}`);
for (const [job, name] of Object.entries(UNCONSUMED_JOBS)) declare(job, name, 'UNCONSUMED_JOBS');
for (const [job, { name, owner }] of declared) {
const entry = workflowJobs.get(job);
if (!entry) { problems.push(`${job}: ${owner} names a job that validate.yml does not have`); continue; }
const actual = entry.matrix ? staticNamePrefix(entry.name) : entry.name;
if (actual !== name) problems.push(`${job}: validate.yml names it ${JSON.stringify(entry.name)}, ${owner} expects ${JSON.stringify(name)}${entry.matrix ? ' as its prefix' : ''}`);
}
for (const job of workflowJobs.keys()) {
if (!declared.has(job)) problems.push(`${job}: validate.yml job is in neither JOB_RULES nor UNCONSUMED_JOBS`);
}
return problems;
}
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)}`;
}
// ---------------------------------------------------------------------------
// Составное evidence (#573): proof называет ОТДЕЛЬНО продуктовое дерево,
// overlay принятых эталонов и content-ключи реюзных job. Один `tree`
// кандидата отвечал только «то же ли это дерево»; после приёмки эталонов
// ответ всегда «нет», хотя продукт не менялся, — и потребитель не мог ни
// объяснить, ни проверить, почему smoke и perf законно переиспользованы, а
// golden перегнан. Теперь он сверяет каждую часть с тем, что сам считает на
// checkout кандидата (`expected` в evaluateCiProof).
const overlayMatchers = BASELINE_OVERLAY.map((glob) => globToRegExp(glob));
export const isBaselineOverlayPath = (path) => overlayMatchers.some((re) => re.test(path));
/**
* Identity продуктового дерева: строки `git ls-tree -r <sha>` без overlay
* эталонов. Два коммита с одним значением отличаются только принятыми
* кадрами и их индексом — ровно случай baseline-only коммита.
*/
export function productTreeId(lsTreeText) {
const lines = String(lsTreeText).split(/\r?\n/).filter(Boolean)
.filter((line) => !isBaselineOverlayPath(line.split('\t').slice(1).join('\t')))
.sort();
if (!lines.length) throw new Error('product tree is empty — ls-tree output has no entries');
const hash = createHash('sha256');
for (const line of lines) { hash.update(line); hash.update('\0'); }
return hash.digest('hex');
}
/** Run из трейлера `Baseline-Reviewed: …/actions/runs/<id>`; null, когда трейлера нет. */
export function baselineReviewedRun(commitMessage) {
const trailer = String(commitMessage).match(/^Baseline-Reviewed:\s*(\S+)\s*$/mi)?.[1];
if (!trailer) return null;
const id = Number(trailer.match(/\/actions\/runs\/(\d+)/)?.[1] || 0);
if (!id) throw new Error(`Baseline-Reviewed trailer does not name an actions run: ${trailer}`);
return id;
}
const gitOut = (root, args) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
/**
* Evidence по checkout: то, что пишет job `proof`, и то, что независимо
* считает потребитель на том же SHA. Ключи — те же `reuseKey`, что у job
* `reuse`; `keys` в аргументе (её outputs) обязаны совпасть — иначе
* дерево, по которому приняли решение о реюзе, не то, по которому написан proof.
*/
export function localEvidence(root = process.cwd(), { keys = null, rev = 'HEAD' } = {}) {
const computed = Object.fromEntries(REUSE_JOBS.map((job) => [job, reuseKey(root, job)]));
if (keys) {
for (const job of REUSE_JOBS) {
if (keys[job] && keys[job] !== computed[job]) {
throw new Error(`${job}: reuse job key ${keys[job]} differs from the proof checkout key ${computed[job]}`);
}
}
}
const overlayDir = BASELINE_OVERLAY[0].replace(/\/\*\*$/, '');
let baselineTree = null;
try { baselineTree = gitOut(root, ['rev-parse', `${rev}:${overlayDir}`]).trim() || null; } catch { baselineTree = null; }
const manifestPath = resolve(root, overlayDir, 'baselines-index.json');
const manifestBytes = existsSync(manifestPath) ? readFileSync(manifestPath) : null;
const manifestSha256 = manifestBytes
? createHash('sha256').update(manifestBytes).digest('hex') : null;
let reviewedLocal = null;
if (manifestBytes) {
try { reviewedLocal = JSON.parse(manifestBytes).localAttestation?.sha256 ?? null; }
catch { reviewedLocal = null; }
}
return {
product: { tree: productTreeId(gitOut(root, ['ls-tree', '-r', '--full-tree', rev])) },
baselines: {
tree: baselineTree,
manifestSha256,
reviewedRun: baselineReviewedRun(gitOut(root, ['log', '-1', '--format=%B', rev])),
reviewedLocal,
},
keys: computed,
};
}
const EVIDENCE_FIELDS = [
['product.tree', (e) => e?.product?.tree],
['baselines.tree', (e) => e?.baselines?.tree ?? null],
['baselines.manifestSha256', (e) => e?.baselines?.manifestSha256 ?? null],
['baselines.reviewedRun', (e) => e?.baselines?.reviewedRun ?? null],
['baselines.reviewedLocal', (e) => e?.baselines?.reviewedLocal ?? null],
...REUSE_JOBS.map((job) => [`keys.${job}`, (e) => e?.keys?.[job]]),
];
/** Первое расхождение evidence proof с ожиданием потребителя, либо null. */
export function evidenceMismatch(actual, expected) {
for (const [name, read] of EVIDENCE_FIELDS) {
const have = read(actual);
const want = read(expected);
if (want === undefined) continue;
if (have !== want) return `${name}: proof says ${have ?? 'null'}, candidate checkout says ${want ?? 'null'}`;
}
return null;
}
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.geometry_parity)) ids.push('geometry_parity');
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, evidence = null,
}) {
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),
geometry_parity: asBool(changes.geometry_parity),
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.geometry_parity) executedOrReused('geometry_parity');
if (selection.backend) executedOrReused('backend');
const requiredChecks = requiredCheckIds({ request, selection });
// #573: content-ключ записывается и у ИСПОЛНЕННОЙ реюзной job — иначе
// следующий прогон не докажет, что его reuse ссылается на те же входы.
if (evidence?.keys) {
for (const id of REUSE_JOBS) {
if (checks[id]?.mode === 'executed') checks[id].key = evidence.keys[id] || null;
if (checks[id]?.mode === 'reused' && evidence.keys[id] && checks[id].reuse?.key !== evidence.keys[id]) {
throw new Error(`${id}: reuse marker key ${checks[id].reuse?.key} differs from the candidate key ${evidence.keys[id]}`);
}
}
}
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,
...(evidence ? { evidence } : {}),
};
}
const sortedUnique = (values) => [...new Set(values)].sort();
const sameSet = (a, b) => JSON.stringify(sortedUnique(a)) === JSON.stringify(sortedUnique(b));
// Каждый экземпляр — ровно одна job с этим именем, и она зелёная. Лишняя job
// с похожим именем (другой размер матрицы в чужом YAML) доказательством не
// считается, недостающая — проваливает проверку.
function executedCheckIsGreen(rules, jobs) {
const list = Array.isArray(jobs) ? jobs : [];
return (rules || []).every((rule) => rule.names.every((name) => {
const matches = list.filter((job) => job?.name === name);
return matches.length === 1 && matches[0].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, expected = null, reviewedRun = undefined,
workflowJobs = undefined,
}) {
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 identity = {
runId: runIdOf(run), attempt: runAttemptOf(run), sha: candidate.sha || runShaOf(run), tree: candidate.tree,
};
if (proof.run?.id !== identity.runId || proof.run?.attempt !== identity.attempt
|| proof.run?.workflow !== 'validate.yml' || proof.candidate?.sha !== identity.sha
|| (identity.tree && proof.candidate?.tree !== identity.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');
// #573: потребитель, у которого есть checkout кандидата (release), сверяет
// составное evidence, а не верит ему. Proof без блока при наличии ожиданий
// устарел. Объявленный run просмотра кадров проверяется ТОЛЬКО здесь же:
// review и merge ожиданий не передают и лишнего запроса к API не делают
// (ревью r1, M1) — их семантика #541 не меняется.
if (expected) {
if (!proof.evidence) return result('stale', 'proof predates composite evidence (#573)');
const mismatch = evidenceMismatch(proof.evidence, expected);
if (mismatch) return result('failed', `evidence does not match the candidate checkout — ${mismatch}`);
const declared = proof.evidence.baselines?.reviewedRun ?? null;
if (declared) {
const source = reviewedRun?.run;
if (!source || runIdOf(source) !== declared || !/validate\.yml$/.test(String(source.path || source.workflow || 'validate.yml'))
|| source.status !== 'completed' || source.conclusion === 'cancelled') {
return result('failed', `Baseline-Reviewed run ${declared} is missing, cancelled or not a Validate run`);
}
}
}
if (proof.evidence) {
for (const id of REUSE_JOBS) {
const claim = proof.checks?.[id];
if (claim?.mode === 'reused' && claim.reuse?.key !== proof.evidence.keys?.[id])
return result('failed', `${id}: reused marker key differs from the candidate content key`);
}
}
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'}`);
let rules;
try {
rules = resolveJobRules(workflowJobs);
} catch (error) {
return result('failed', `job-name contract with validate.yml is broken (#622): ${error.message}`);
}
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(rules[id], jobs)) return result('failed', `${id}: claimed execution is absent, incomplete or not green`);
continue;
}
if (claim.mode !== 'reused'
|| !['smoke', 'golden', 'performance_smoke', 'geometry_parity', '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(rules[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`);
}
/**
* Evaluations arrive newest first. Cancelled and stale runs do not describe
* the requested policy; the newest remaining run is the verdict. In
* particular, a later failed full run must not be hidden by an older green
* proof for the same candidate (#656).
*/
export function selectCiProofVerdict(evaluations) {
const relevant = (evaluations || []).filter(
(item) => item?.status !== 'cancelled' && item?.status !== 'stale',
);
if (relevant.length) return relevant[0];
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, withReviewedRun = false }) {
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 || [] });
}
// #573: объявленный человеком run просмотра кадров обязан существовать —
// спрашивает только release-потребитель (`withReviewedRun`); review и merge
// этот запрос не делают и от доступности старого run не зависят.
let reviewedRun;
const declared = proof?.evidence?.baselines?.reviewedRun;
if (withReviewedRun && declared) {
try {
reviewedRun = { run: await githubJson(`https://api.github.com/repos/${repo}/actions/runs/${declared}`, token, fetchImpl) };
} catch {
reviewedRun = null;
}
}
return { proof, jobs, reuseRuns, ...(reviewedRun !== undefined ? { reviewedRun } : {}) };
}
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 needs = JSON.parse(process.env.NEEDS_JSON || '{}');
const reuseOutputs = needs?.reuse?.outputs || {};
const evidence = localEvidence(process.cwd(), {
keys: Object.fromEntries(REUSE_JOBS.map((job) => [job, reuseOutputs[`${job}_key`] || null])),
});
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,
requestedFull: process.env.REQUEST_FULL,
requestedMutants: process.env.REQUEST_MUTANTS,
evidence,
});
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(', ')})`);
console.log(`product tree ${evidence.product.tree.slice(0, 12)} · baselines ${evidence.baselines.tree?.slice(0, 12) || 'none'}`
+ ` · reviewed run ${evidence.baselines.reviewedRun || 'none'}`
+ ` · reviewed local ${evidence.baselines.reviewedLocal?.slice(0, 12) || 'none'}`);
} else {
console.error('usage: ci-proof.mjs --emit=<proof.json> | --marker=<.reuse-marker>');
process.exitCode = 2;
}
}