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
+41 -2
View File
@@ -1,10 +1,12 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import {
classifyValidateProofs, classifyValidateRuns, latestRelevantRun, workflowRunsUrl,
candidateExpectations, classifyValidateProofs, classifyValidateRuns, latestRelevantRun, workflowRunsUrl,
} from '../scripts/release-gate.mjs';
import { buildCiProof } from '../scripts/ci-proof.mjs';
import { buildCiProof, localEvidence } from '../scripts/ci-proof.mjs';
const SHA = 'a'.repeat(40);
const TREE = 'b'.repeat(40);
@@ -124,3 +126,40 @@ test('#541: the release documents describe proof semantics', () => {
const performance = readFileSync(new URL('../demo/performance/README.md', import.meta.url), 'utf8');
assert.match(performance, /latest\nnon-cancelled run on the SHA/);
});
// #573: гейт релиза стоит на checkout кандидата и сверяет составное evidence
// proof с тем, что считает сам; чужой checkout — честное «проверяю только по
// GitHub», а не молчаливый пропуск.
test('#573: ожидания считаются только на checkout кандидата и уходят в classifyValidateProofs', async () => {
const root = fileURLToPath(new URL('..', import.meta.url));
const head = execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
const notes = [];
const foreign = candidateExpectations({ sha: 'f'.repeat(40), root, log: (line) => notes.push(line) });
assert.equal(foreign, null);
assert.match(notes[0], /is not the candidate ffffffff — proof evidence is verified against GitHub only/);
const own = candidateExpectations({ sha: head, root, log: (line) => notes.push(line) });
assert.deepEqual(own, localEvidence(root));
// proof без evidence при наличии ожиданий — stale, а старее его нет → missing; с evidence и совпадением — green
const legacy = proofContext({ id: 40 });
const verdict = await classifyValidateProofs({
runs: [legacy.run], repo: 'x/y', sha: SHA, tree: TREE, token: 'x', expected: own,
loadContext: async () => legacy.context,
});
assert.equal(verdict.status, 'missing', verdict.note);
const modern = proofContext({ id: 41 });
modern.context.proof.evidence = structuredClone(own);
const green = await classifyValidateProofs({
runs: [modern.run], repo: 'x/y', sha: SHA, tree: TREE, token: 'x', expected: own,
loadContext: async () => modern.context,
});
assert.equal(green.status, 'green', green.note);
const substituted = structuredClone(own);
substituted.keys.golden = '0'.repeat(64);
const red = await classifyValidateProofs({
runs: [modern.run], repo: 'x/y', sha: SHA, tree: TREE, token: 'x', expected: substituted,
loadContext: async () => modern.context,
});
assert.equal(red.status, 'failed');
assert.match(red.note, /keys\.golden/);
});