mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Решения владельца: 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
589 lines
31 KiB
JavaScript
589 lines
31 KiB
JavaScript
#!/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;
|
||
}
|
||
}
|