ci: proof различает продуктовое дерево и overlay эталонов (#573)

Приёмка эталонов на beta.3 (`ad4000f9`) стоила второго полного Validate —
22 минуты, из них 17–22 на шард мутантов. Причина одна: корпус отпечатка
(`source-fingerprint.mjs`) называет `demo/golden` строкой-каталогом, а
замыкание входов раскрывало каталог во все текстовые файлы под ним, включая
`baselines-index.json`. Индекс становился входом smoke, performance_smoke и
каждого гарда через `serve.mjs`: на реальной паре C→B ключи smoke/perf были
DIFFERENT, отпечатки 181 из 183 браузерных свидетелей менялись, журнал их не
пропускал.

- `check-inputs.mjs`: `BASELINE_OVERLAY` — раскрытие каталога не выдаёт
  overlay; явный корень golden и явная ссылка на файл — как были. На паре
  C→B: ключи smoke/perf/parity/backend same, golden DIFFERENT; отпечатки
  743 из 744 равны; план мутантов B с журналом C — 0–1 на шард вместо 38–44
- `ci-proof.mjs`: составное evidence — product tree без overlay, overlay
  (tree, sha256 индекса, run из `Baseline-Reviewed`), content-ключи всех
  реюзных job (исполненных тоже); `evaluateCiProof({expected, reviewedRun})`
  сверяет с локальным расчётом, fail-closed на ключ, tree, индекс, reviewed
  run, маркер с чужим ключом; proof без evidence при ожиданиях — stale
- `release-gate.mjs` / `release-prerelease.mjs`: ожидания считаются на
  checkout кандидата (`candidateExpectations`), чужой checkout — notice
- мутанты: `baseline-overlay-leaks-into-every-key`,
  `proof-trusts-evidence-it-could-verify`,
  `reused-marker-key-unchecked-against-candidate`,
  `product-tree-identity-counts-baselines`; перенацелен
  `ci-proof-ignores-run-attempt`
- docs: TESTING (правило overlay), DEVELOPMENT (evidence в release proof),
  STATUS

Issue: #573
User-Visible: no
This commit is contained in:
Claude
2026-09-17 22:00:15 +03:00
parent 06bf9b6921
commit 47f36e571c
14 changed files with 578 additions and 29 deletions
+155 -9
View File
@@ -4,9 +4,13 @@
// а skipped job без доказанного content-addressed reuse ничего не доказывает.
import { inflateRawSync } from 'node:zlib';
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
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';
export const CI_PROOF_SCHEMA = 'houseplan-ci-proof/v1';
export const CI_PROOF_ARTIFACT_PREFIX = 'ci-proof';
@@ -52,6 +56,95 @@ 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 manifestSha256 = existsSync(manifestPath)
? createHash('sha256').update(readFileSync(manifestPath)).digest('hex') : 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])),
},
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],
...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;
@@ -82,7 +175,7 @@ const reuseClaim = (outputs, id) => ({
/** Build the immutable JSON uploaded by the final Validate job. */
export function buildCiProof({
candidateSha, candidateTree, runId, attempt, event, needs,
requestedFull = false, requestedMutants = false,
requestedFull = false, requestedMutants = false, evidence = null,
}) {
const changes = needs?.changes?.outputs || {};
const reuse = needs?.reuse?.outputs || {};
@@ -130,6 +223,16 @@ export function buildCiProof({
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 },
@@ -140,6 +243,7 @@ export function buildCiProof({
executedChecks: requiredChecks.filter((id) => checks[id]?.mode === 'executed'),
reusedChecks: requiredChecks.filter((id) => checks[id]?.mode === 'reused'),
checks,
...(evidence ? { evidence } : {}),
};
}
@@ -160,19 +264,21 @@ function executedCheckIsGreen(id, jobs) {
* 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 }) {
export function evaluateCiProof({
run, proof, jobs = [], reuseRuns = new Map(), candidate = {}, policy, expected = null, reviewedRun = 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 expected = {
const identity = {
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)) {
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))
@@ -181,6 +287,28 @@ export function evaluateCiProof({ run, proof, jobs = [], reuseRuns = new Map(),
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 кандидата, сверяет составное
// evidence, а не верит ему. Proof без блока при наличии ожиданий устарел.
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}`);
}
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 declared = proof.evidence.baselines?.reviewedRun ?? null;
if (declared && reviewedRun !== undefined) {
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`);
}
}
}
const derived = requiredCheckIds(proof);
if (!sameSet(derived, proof.requiredChecks || []))
return result('failed', 'proof required-check list is incomplete or inconsistent');
@@ -310,7 +438,17 @@ export async function loadGithubProofContext({ repo, run, token, fetchImpl = fet
);
reuseRuns.set(sourceKey, { run: sourceRun, jobs: sourceJobs?.jobs || [] });
}
return { proof, jobs, reuseRuns };
// #573: объявленный человеком run просмотра кадров обязан существовать.
let reviewedRun;
const declared = proof?.evidence?.baselines?.reviewedRun;
if (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 };
}
if (isMainModule(import.meta.url)) {
@@ -323,20 +461,28 @@ if (isMainModule(import.meta.url)) {
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: JSON.parse(process.env.NEEDS_JSON || '{}'),
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'}`);
} else {
console.error('usage: ci-proof.mjs --emit=<proof.json> | --marker=<.reuse-marker>');
process.exitCode = 2;