diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 6a509fe9..50f88aee 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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 diff --git a/scripts/ci-proof.mjs b/scripts/ci-proof.mjs index e52ae760..b38eece5 100644 --- a/scripts/ci-proof.mjs +++ b/scripts/ci-proof.mjs @@ -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)) { diff --git a/scripts/release-gate.mjs b/scripts/release-gate.mjs index d76b7620..d21883f5 100644 --- a/scripts/release-gate.mjs +++ b/scripts/release-gate.mjs @@ -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)) { diff --git a/test/ci-proof.test.mjs b/test/ci-proof.test.mjs index b6e01b2a..db720c7a 100644 --- a/test/ci-proof.test.mjs +++ b/test/ci-proof.test.mjs @@ -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',