ci: reviewed run судится только release-потребителем (#573, ревью r1 M1)

`loadGithubProofContext` спрашивал объявленный `Baseline-Reviewed` run для
любого потребителя, а `evaluateCiProof` судил его при `reviewedRun: null` —
merge и review начинали зависеть от доступности старого run по чужой причине.
Теперь запрос делается только с `withReviewedRun` (release-gate передаёт его
вместе с ожиданиями), а проверка стоит внутри `if (expected)` — рядом со
сверкой evidence, где ей и место. Тест: без ожиданий merge/review green при
reviewedRun undefined/null/пустом; фейковый fetch доказывает, что запроса нет.

Issue: #573
User-Visible: no
This commit is contained in:
Claude
2026-09-17 22:26:19 +03:00
parent da156dd10b
commit 2ea1e5e62a
4 changed files with 86 additions and 20 deletions
+3 -1
View File
@@ -501,7 +501,9 @@ executed or reused. Release consumers standing on the candidate checkout
(`release-gate.mjs`, `release-prerelease.mjs`) recompute all of it locally and
fail closed on any mismatch, on a reused marker whose key is not the
candidate's, and on a declared review run that does not exist, was cancelled
or is not a Validate run; a proof without the block is stale for them. The
or is not a Validate run; a proof without the block is stale for them. Review
and merge consumers pass no expectations, do not query the declared review run
and keep the #541 semantics unchanged. The
practical consequence is the beta.3 path: a candidate red only in golden,
then a baseline-only commit that reuses smoke, performance smoke, parity and
backend from the candidate's green jobs, skips every caught witness in the
+19 -14
View File
@@ -287,12 +287,23 @@ export function evaluateCiProof({
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 без блока при наличии ожиданий устарел.
// #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) {
@@ -300,14 +311,6 @@ export function evaluateCiProof({
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 || []))
@@ -404,7 +407,7 @@ export async function githubCandidateTree({ repo, sha, token, fetchImpl = fetch
return row?.tree?.sha || null;
}
export async function loadGithubProofContext({ repo, run, token, fetchImpl = fetch }) {
export async function loadGithubProofContext({ repo, run, token, fetchImpl = fetch, withReviewedRun = false }) {
const runId = runIdOf(run);
const attempt = runAttemptOf(run);
const name = ciProofArtifactName(runId, attempt);
@@ -438,17 +441,19 @@ export async function loadGithubProofContext({ repo, run, token, fetchImpl = fet
);
reuseRuns.set(sourceKey, { run: sourceRun, jobs: sourceJobs?.jobs || [] });
}
// #573: объявленный человеком run просмотра кадров обязан существовать.
// #573: объявленный человеком run просмотра кадров обязан существовать —
// спрашивает только release-потребитель (`withReviewedRun`); review и merge
// этот запрос не делают и от доступности старого run не зависят.
let reviewedRun;
const declared = proof?.evidence?.baselines?.reviewedRun;
if (declared) {
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 };
return { proof, jobs, reuseRuns, ...(reviewedRun !== undefined ? { reviewedRun } : {}) };
}
if (isMainModule(import.meta.url)) {
+2 -1
View File
@@ -55,7 +55,8 @@ 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, expected = null,
loadContext = (run) => loadGithubProofContext({ repo, run, token, fetchImpl }),
// #573: reviewed run спрашивается у GitHub только вместе с ожиданиями (release)
loadContext = (run) => loadGithubProofContext({ repo, run, token, fetchImpl, withReviewedRun: Boolean(expected) }),
}) {
const evaluations = [];
for (const run of newestFirst(runs)) {
+62 -4
View File
@@ -5,8 +5,8 @@ import { deflateRawSync } from 'node:zlib';
import { fileURLToPath } from 'node:url';
import {
CI_PROOF_POLICIES, baselineReviewedRun, buildCiProof, evaluateCiProof, localEvidence, parseReuseMarker,
productTreeId, readCiProofArtifact, requiredCheckIds, selectCiProofVerdict,
CI_PROOF_POLICIES, baselineReviewedRun, buildCiProof, evaluateCiProof, loadGithubProofContext, localEvidence,
parseReuseMarker, productTreeId, readCiProofArtifact, requiredCheckIds, selectCiProofVerdict,
} from '../scripts/ci-proof.mjs';
import { REUSE_JOBS } from '../scripts/check-inputs.mjs';
import { reuseKey } from '../scripts/gate-reuse.mjs';
@@ -14,6 +14,25 @@ import { reuseKey } from '../scripts/gate-reuse.mjs';
export const SHA = 'a'.repeat(40);
export const TREE = 'b'.repeat(40);
/** Минимальный ZIP с одним `proof.json` (stored), как читает readCiProofArtifact. */
function zipWith(proof) {
const body = Buffer.from(JSON.stringify(proof));
const name = Buffer.from('proof.json');
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0, 8);
local.writeUInt32LE(body.length, 18); local.writeUInt32LE(body.length, 22); local.writeUInt16LE(name.length, 26);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(0, 10);
central.writeUInt32LE(body.length, 20); central.writeUInt32LE(body.length, 24); central.writeUInt16LE(name.length, 28);
central.writeUInt32LE(0, 42);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0); eocd.writeUInt16LE(1, 8); eocd.writeUInt16LE(1, 10);
eocd.writeUInt32LE(central.length + name.length, 12);
eocd.writeUInt32LE(local.length + name.length + body.length, 16);
const bytes = Buffer.concat([local, name, body, central, name, eocd]);
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
}
const names = {
preflight: 'Предполёт: документация, провенанс, процесс',
changes: 'Классификация изменённых файлов',
@@ -262,8 +281,13 @@ test('#573 AC1: baseline-only коммит — reused smoke/perf из красн
assert.equal(verdict.status, 'green', verdict.note);
assert.deepEqual(fixture.proof.reusedChecks, ['performance_smoke', 'smoke']);
assert.ok(fixture.proof.executedChecks.includes('golden'), 'golden сравнивает с новыми эталонами и перегоняется всегда');
// те же семантики без ожиданий — review/merge потребители не ломаются
assert.equal(evaluateCiProof({ ...fixture, expected: null, reviewedRun: undefined, policy: CI_PROOF_POLICIES.merge }).status, 'green');
// review/merge без ожиданий: reviewed run не спрашивается и не судится (ревью r1, M1) —
// недоступный или пропавший старый run не закрывает merge по чужой причине
for (const reviewedRun of [undefined, null, { run: null }]) {
assert.equal(evaluateCiProof({ ...fixture, expected: null, reviewedRun, policy: CI_PROOF_POLICIES.merge }).status, 'green',
`merge без ожиданий при reviewedRun=${JSON.stringify(reviewedRun)}`);
assert.equal(evaluateCiProof({ ...fixture, expected: null, reviewedRun, policy: CI_PROOF_POLICIES.review }).status, 'green');
}
});
test('#573 AC3: красный smoke кандидата не прячется за зелёной golden — источник reuse обязан быть зелёной job', () => {
@@ -307,6 +331,40 @@ test('#573 AC4: подмена content-ключа, product tree, overlay, инд
assert.equal(evaluateCiProof({ ...fixture, proof: legacy, policy: CI_PROOF_POLICIES.release }).status, 'stale');
});
test('#573 r1 M1: reviewed run спрашивается у GitHub только для release-потребителя с ожиданиями', async () => {
const proof = { ...baselineOnlyFixture().proof };
const urls = [];
const fetchImpl = async (url) => {
urls.push(String(url));
if (/\/artifacts\?name=/.test(url)) return { ok: true, json: async () => ({ artifacts: [] }) };
if (/\/jobs\?/.test(url)) return { ok: true, json: async () => ({ jobs: [] }) };
return { ok: true, json: async () => ({ id: 34853080375, path: '.github/workflows/validate.yml', status: 'completed', conclusion: 'failure' }) };
};
const run = { id: 20, run_attempt: 1 };
// артефакта нет → proof null → reviewed run неизвестен и не спрашивается ни в одном режиме
const bare = await loadGithubProofContext({ repo: 'x/y', run, token: 't', fetchImpl });
assert.ok(!('reviewedRun' in bare));
assert.ok(!urls.some((url) => url.endsWith('/actions/runs/34853080375')), 'без proof спрашивать нечего');
// с proof: merge/review (withReviewedRun по умолчанию false) — запроса нет
const withProof = (withReviewedRun) => loadGithubProofContext({
repo: 'x/y', run, token: 't', withReviewedRun,
fetchImpl: async (url) => (/\/artifacts\?name=/.test(url)
? { ok: true, json: async () => ({ artifacts: [{ name: 'ci-proof-20-1', expired: false, archive_download_url: 'zip://proof' }] }) }
: url === 'zip://proof'
? { ok: true, arrayBuffer: async () => zipWith(proof) }
: fetchImpl(url)),
});
urls.length = 0;
const merge = await withProof(false);
assert.deepEqual(merge.proof.evidence.baselines.reviewedRun, 34853080375);
assert.ok(!('reviewedRun' in merge), 'merge/review не судят reviewed run');
assert.ok(!urls.some((url) => url.endsWith('/actions/runs/34853080375')));
urls.length = 0;
const release = await withProof(true);
assert.equal(release.reviewedRun.run.id, 34853080375);
assert.ok(urls.some((url) => url.endsWith('/actions/runs/34853080375')), 'release спрашивает объявленный run');
});
test('#573: identity продуктового дерева не видит overlay эталонов, но видит всё остальное', () => {
const lines = [
'100644 blob 1111\tsrc/logic.ts',