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
+27 -4
View File
@@ -1,13 +1,30 @@
// Exact-SHA GitHub Actions gate used by release workflows. Prereleases require
// Validate; stable releases additionally require the dedicated full
// performance workflow.
import { execFileSync } from 'node:child_process';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
CI_PROOF_POLICIES, evaluateCiProof, githubCandidateTree,
CI_PROOF_POLICIES, evaluateCiProof, githubCandidateTree, localEvidence,
loadGithubProofContext, selectCiProofVerdict,
} from './ci-proof.mjs';
/**
* #573: ожидания потребителя. Гейты релиза стоят на checkout кандидата, и
* тогда evidence proof (product tree, overlay эталонов, content-ключи,
* reviewed run) не принимается на веру, а сверяется с тем, что посчитано
* здесь. Checkout не на кандидате — считать нечего, и об этом говорится вслух.
*/
export function candidateExpectations({ sha, root = process.cwd(), log = console.log } = {}) {
let head = null;
try { head = execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); } catch { head = null; }
if (!sha || head !== sha) {
log(`::notice::checkout ${head ? head.slice(0, 8) : 'absent'} is not the candidate ${String(sha).slice(0, 8)} — proof evidence is verified against GitHub only (#573)`);
return null;
}
return localEvidence(root);
}
/**
* The verdict is the LATEST run that was not cancelled (#511). A cancelled run
* proves nothing either way — concurrency or a hand superseded it — and the
@@ -37,7 +54,7 @@ const newestFirst = (runs) => [...(Array.isArray(runs) ? runs : [])].sort((a, b)
/** #541: proof-aware verdict shared with review and merge. */
export async function classifyValidateProofs({
runs, repo, sha, tree, token, fetchImpl = fetch,
runs, repo, sha, tree, token, fetchImpl = fetch, expected = null,
loadContext = (run) => loadGithubProofContext({ repo, run, token, fetchImpl }),
}) {
const evaluations = [];
@@ -48,7 +65,7 @@ export async function classifyValidateProofs({
try {
const context = await loadContext(run);
evaluations.push(evaluateCiProof({
run, ...context, candidate: { sha, tree }, policy: CI_PROOF_POLICIES.release,
run, ...context, candidate: { sha, tree }, policy: CI_PROOF_POLICIES.release, expected,
}));
} catch (error) {
evaluations.push({
@@ -72,12 +89,18 @@ const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
export async function waitForGreenWorkflow({
repo, sha, token, workflow = 'validate.yml', label = 'Validate', timeoutMs = 60 * 60 * 1000,
root = process.cwd(),
}) {
if (!repo || !sha || !token || !workflow) throw new Error('repo, sha, token and workflow are required');
const deadline = Date.now() + timeoutMs;
const url = workflowRunsUrl({ repo, workflow, sha });
const proofRequired = workflow === 'validate.yml';
const tree = proofRequired ? await githubCandidateTree({ repo, sha, token }) : null;
const expected = proofRequired ? candidateExpectations({ sha, root }) : null;
if (expected) {
console.log(`candidate evidence: product tree ${expected.product.tree.slice(0, 12)}, baselines `
+ `${expected.baselines.tree?.slice(0, 12) || 'none'}, reviewed run ${expected.baselines.reviewedRun || 'none'} (#573)`);
}
while (true) {
const response = await fetch(url, {
headers: {
@@ -92,7 +115,7 @@ export async function waitForGreenWorkflow({
const runs = Array.isArray(body?.workflow_runs) ? body.workflow_runs : [];
const latest = latestRelevantRun(runs);
const verdict = proofRequired
? await classifyValidateProofs({ runs, repo, sha, tree, token })
? await classifyValidateProofs({ runs, repo, sha, tree, token, expected })
: { status: classifyValidateRuns(runs) === 'success' ? 'green'
: classifyValidateRuns(runs) === 'fail' ? 'failed' : 'pending', url: latest?.html_url, note: '' };
if (verdict.status === 'failed') {