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
+24 -2
View File
@@ -35,6 +35,22 @@ export const BUILD_OUTPUT = [
'dist/**', 'custom_components/houseplan/frontend/**', 'demo/srv/assets/**',
];
/**
* Принятые эталоны и их индекс — overlay поверх продуктового дерева (#573).
*
* С ними сравнивает ровно одна проверка — `golden`, и у неё они стоят явным
* корнем. Никакая другая job их не читает: смок и перф-смок сверяют бандл с
* отпечатком исходников, а отпечаток (`source-fingerprint.mjs`) берёт из
* `demo/golden` только `*.mjs`. Но корпус там назван строкой-каталогом, и
* раскрытие каталога отдавало всем этим проверкам ещё и
* `baselines-index.json` — так приёмка 13 кадров на `v1.76.0-beta.3`
* (`ad4000f9`) сменила ключи smoke и performance_smoke и отпечатки 181 из 183
* свидетелей с браузерным гардом, и второй полный Validate повторил 22 минуты
* работы, которую первый уже сделал. Раскрытие каталога overlay не выдаёт;
* явный корень и явная ссылка на файл — как были.
*/
export const BASELINE_OVERLAY = ['demo/golden/baselines/**'];
/**
* Не входы Validate — с причиной. Каждая запись отвечает на вопрос «кто это
* исполняет и почему не Validate».
@@ -83,6 +99,7 @@ export const globToRegExp = (glob) => {
return new RegExp(`^${re}$`);
};
const matchesAny = (file, globs) => globs.some((glob) => globToRegExp(glob).test(file));
export const isBaselineOverlay = (file) => matchesAny(file, BASELINE_OVERLAY);
/** Отслеживаемые файлы (git), либо обход дерева там, где git недоступен. */
export function trackedFiles(root) {
@@ -253,8 +270,13 @@ export function closure(root, entries, { tracked = trackedFiles(root), stopAt =
for (const ref of data) {
if (trackedSet.has(ref)) { note(ref, file); seen.add(ref); continue; }
// каталог по строке — данные; бинарные файлы под ним код по строке не
// читает (эталоны golden входят в свою проверку явным корнем)
if (isDir(ref)) for (const f of tracked) if (f.startsWith(`${ref}/`) && !BINARY.test(f)) { note(f, file); seen.add(f); }
// читает, а overlay эталонов принадлежит только своей проверке (#573):
// и то и другое входит в golden явным корнем
if (isDir(ref)) {
for (const f of tracked) {
if (f.startsWith(`${ref}/`) && !BINARY.test(f) && !isBaselineOverlay(f)) { note(f, file); seen.add(f); }
}
}
}
}
return [...seen].sort();
+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;
+50 -2
View File
@@ -9418,8 +9418,8 @@ const MUTANT_DEFINITIONS = [
+ 'attempt cannot vouch for the current result (#541)',
patches: [{
file: 'scripts/ci-proof.mjs',
find: ' if (proof.run?.id !== expected.runId || proof.run?.attempt !== expected.attempt\n',
replace: ' if (proof.run?.id !== expected.runId || false && proof.run?.attempt !== expected.attempt\n',
find: ' if (proof.run?.id !== identity.runId || proof.run?.attempt !== identity.attempt\n',
replace: ' if (proof.run?.id !== identity.runId || false && proof.run?.attempt !== identity.attempt\n',
}],
},
{
@@ -9904,6 +9904,54 @@ const MUTANT_DEFINITIONS = [
replace: ' "zones_v1": ("zones", ()),',
}],
},
{
id: 'baseline-overlay-leaks-into-every-key',
guard: 'node --test --test-name-pattern="#573" test/check-inputs.test.mjs test/gate-reuse.test.mjs test/mutation-gate.test.mjs',
because: '#573: строка-каталог `demo/golden` в корпусе отпечатка раскрывалась во все текстовые '
+ 'файлы под ним, и индекс эталонов становился входом smoke, perf и 181 из 183 браузерных '
+ 'свидетелей. Без фильтра overlay приёмка кадров снова перегоняет 22 минуты чужой работы, '
+ 'а ключи, которые proof предъявляет как «те же входы», меняются от PNG',
patches: [{
file: 'scripts/check-inputs.mjs',
find: " if (f.startsWith(`${ref}/`) && !BINARY.test(f) && !isBaselineOverlay(f)) { note(f, file); seen.add(f); }",
replace: " if (f.startsWith(`${ref}/`) && !BINARY.test(f)) { note(f, file); seen.add(f); }",
}],
},
{
id: 'proof-trusts-evidence-it-could-verify',
guard: 'node --test --test-name-pattern="#573" test/ci-proof.test.mjs test/release-gate.test.mjs',
because: '#573: потребитель с checkout кандидата обязан СВЕРЯТЬ evidence, а не верить. Без '
+ 'сравнения подменённый content-ключ, product tree или индекс эталонов проходят как свои — '
+ 'и proof снова говорит только «вот SHA»',
patches: [{
file: 'scripts/ci-proof.mjs',
find: ' const mismatch = evidenceMismatch(proof.evidence, expected);',
replace: ' const mismatch = evidenceMismatch(proof.evidence, proof.evidence);',
}],
},
{
id: 'reused-marker-key-unchecked-against-candidate',
guard: 'node --test --test-name-pattern="#573" test/ci-proof.test.mjs',
because: '#573: маркер реюза несёт ключ источника; если он не сверяется с ключом кандидата, '
+ 'зелёная job с ДРУГИМИ входами засчитывается этому дереву',
patches: [{
file: 'scripts/ci-proof.mjs',
find: " if (claim?.mode === 'reused' && claim.reuse?.key !== proof.evidence.keys?.[id])",
replace: " if (claim?.mode === 'reused' && claim.reuse?.key !== (proof.evidence.keys?.[id] ?? claim.reuse?.key) && false)",
}],
},
{
id: 'product-tree-identity-counts-baselines',
guard: 'node --test --test-name-pattern="#573" test/ci-proof.test.mjs',
because: '#573: identity продуктового дерева существует ради одного сравнения — «C и B '
+ 'отличаются только принятыми кадрами». Считая overlay, оно совпадает с `tree` кандидата и '
+ 'перестаёт отличать приёмку эталонов от правки продукта',
patches: [{
file: 'scripts/ci-proof.mjs',
find: " .filter((line) => !isBaselineOverlayPath(line.split('\\t').slice(1).join('\\t')))",
replace: " .filter((line) => typeof line === 'string')",
}],
},
{
id: 'room-orphan-colour-wins-again',
guard: 'node --test test/logic.test.mjs',
+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') {
+4 -2
View File
@@ -12,7 +12,7 @@ import { spawnSync } from 'node:child_process';
import { createInterface } from 'node:readline/promises';
import { stdin, stdout } from 'node:process';
import { assertReleaseContract } from './release-contract.mjs';
import { classifyValidateProofs } from './release-gate.mjs';
import { candidateExpectations, classifyValidateProofs } from './release-gate.mjs';
import { assertBundleManifest } from './bundle-tree.mjs';
import { SUMS_FILE, compareSums, formatSums, parseSums, sumsOfDirectory } from './release-assets.mjs';
import {
@@ -416,7 +416,9 @@ if (invokedDirectly) {
]);
const tree = run('git', ['rev-parse', `${sha}^{tree}`]).stdout;
const token = run('gh', ['auth', 'token']).stdout;
const verdict = await classifyValidateProofs({ runs, repo, sha, tree, token });
// #573: локальный публикатор стоит на checkout кандидата — сверяет evidence.
const expected = candidateExpectations({ sha, root });
const verdict = await classifyValidateProofs({ runs, repo, sha, tree, token, expected });
if (verdict.status !== 'green') {
throw new Error(`Exact-SHA Validate proof is ${verdict.status} for ${sha}: ${verdict.note}`);
}