import assert from 'node:assert/strict'; import test from 'node:test'; import { deflateRawSync } from 'node:zlib'; import { fileURLToPath } from 'node:url'; import { CI_PROOF_POLICIES, MUTANT_JOB_PREFIX, 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'; import { jobInstanceNames, parseWorkflowJobs, validateJobs, VALIDATE_WORKFLOW_PATH } from '../scripts/workflow-jobs.mjs'; import { readFileSync } from 'node:fs'; 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); } // #622: имена job — из validate.yml, а не копией строк: тест, который держит // свои имена, зеленеет и тогда, когда потребитель с файлом разошёлся. const WORKFLOW = validateJobs(); const namesOf = (id) => jobInstanceNames(WORKFLOW.get(id)); const names = { preflight: namesOf('preflight')[0], changes: namesOf('changes')[0], reuse: namesOf('reuse')[0], frontend: namesOf('frontend')[0], hacs: namesOf('hacs')[0], hassfest: namesOf('hassfest')[0], smokeDone: namesOf('smoke_done')[0], golden: namesOf('golden')[0], performance: namesOf('performance_smoke')[0], geometryParity: namesOf('geometry_parity')[0], backend: namesOf('backend')[0], }; const success = (name) => ({ name, conclusion: 'success' }); const mutantJobs = () => namesOf('changed_mutants').map(success); const smokeJobs = () => namesOf('smoke').map(success); export function proofFixture({ id = 10, attempt = 2, full = true, mutants = true, frontend = true, geometryParity = true, backend = true, integration = true, conclusion = 'success', } = {}) { const needs = { preflight: { result: 'success' }, changes: { result: 'success', outputs: { heavy: String(full), mutants_requested: String(mutants), frontend: String(frontend), geometry_parity: String(geometryParity), backend: String(backend), integration: String(integration), }, }, reuse: { result: 'success', outputs: {} }, frontend: { result: frontend ? 'success' : 'skipped' }, hacs: { result: integration ? 'success' : 'skipped' }, hassfest: { result: integration ? 'success' : 'skipped' }, changed_mutants: { result: mutants ? 'success' : 'skipped' }, smoke: { result: full ? 'success' : 'skipped' }, smoke_done: { result: full ? 'success' : 'skipped' }, golden: { result: full ? 'success' : 'skipped' }, performance_smoke: { result: full ? 'success' : 'skipped' }, geometry_parity: { result: geometryParity ? 'success' : 'skipped' }, backend: { result: backend ? 'success' : 'skipped' }, }; const proof = buildCiProof({ candidateSha: SHA, candidateTree: TREE, runId: id, attempt, event: 'workflow_dispatch', needs, }); const jobs = [success(names.preflight), success(names.changes), success(names.reuse)]; if (frontend) jobs.push(success(names.frontend)); if (integration) jobs.push(success(names.hacs), success(names.hassfest)); if (mutants) jobs.push(...mutantJobs()); if (full) jobs.push(...smokeJobs(), success(names.smokeDone), success(names.golden), success(names.performance)); if (geometryParity) jobs.push(success(names.geometryParity)); if (backend) jobs.push(success(names.backend)); const run = { databaseId: id, attempt, status: 'completed', conclusion, event: 'workflow_dispatch', headSha: SHA, url: `https://run/${id}`, }; return { run, proof, jobs, reuseRuns: new Map(), candidate: { sha: SHA, tree: TREE } }; } test('#541: proof records candidate identity, request and exact required check set', () => { const fixture = proofFixture(); assert.deepEqual(fixture.proof.candidate, { sha: SHA, tree: TREE }); assert.deepEqual(fixture.proof.run, { id: 10, attempt: 2, workflow: 'validate.yml', event: 'workflow_dispatch', }); assert.deepEqual(fixture.proof.requiredChecks, [ 'preflight', 'changes', 'reuse', 'frontend', 'integration', 'mutants', 'smoke', 'golden', 'performance_smoke', 'geometry_parity', 'backend', ]); assert.deepEqual(fixture.proof.requiredChecks, requiredCheckIds(fixture.proof)); }); test('#541 AC: one state machine gives review, merge and release the same terminal semantics', () => { const full = proofFixture(); for (const policy of Object.values(CI_PROOF_POLICIES)) { assert.equal(evaluateCiProof({ ...full, policy }).status, 'green', policy.name); assert.equal(evaluateCiProof({ ...full, run: null, policy }).status, 'missing', policy.name); assert.equal(evaluateCiProof({ ...full, run: { ...full.run, status: 'in_progress' }, policy }).status, 'pending', policy.name); assert.equal(evaluateCiProof({ ...full, run: { ...full.run, conclusion: 'cancelled' }, policy }).status, 'cancelled', policy.name); assert.equal(evaluateCiProof({ ...full, run: { ...full.run, conclusion: 'failure' }, policy }).status, 'failed', policy.name); assert.equal(evaluateCiProof({ ...full, candidate: { sha: SHA, tree: 'c'.repeat(40) }, policy }).status, 'stale', policy.name); } }); test('#541 AC: full red followed by light green still blocks release; a later full green refreshes it', () => { const redFull = proofFixture({ id: 20, conclusion: 'failure' }); const lightGreen = proofFixture({ id: 21, full: false, backend: false, integration: false }); const red = evaluateCiProof({ ...redFull, policy: CI_PROOF_POLICIES.release }); const light = evaluateCiProof({ ...lightGreen, policy: CI_PROOF_POLICIES.release }); assert.equal(light.status, 'stale'); assert.equal(selectCiProofVerdict([light, red]).status, 'failed'); const newerFull = evaluateCiProof({ ...proofFixture({ id: 22 }), policy: CI_PROOF_POLICIES.release }); assert.equal(selectCiProofVerdict([newerFull, light, red]).status, 'green'); assert.equal(selectCiProofVerdict([red, newerFull]).status, 'green', '#619: a failed duplicate cannot hide a complete green proof for the same candidate'); }); test('#601 AC3: release policy accepts a full proof without requested mutants; light stays stale; review/merge still demand them', () => { // Кандидат беты (`Release:` на dev, event=push) с #601 несёт тяжёлые гейты без // мутантов — proof без запрошенных mutant-jobs для релиза зелёный. const beta = proofFixture({ id: 30, mutants: false }); beta.run.event = 'push'; beta.proof.run.event = 'push'; assert.equal(CI_PROOF_POLICIES.release.mutants, false); assert.equal(evaluateCiProof({ ...beta, policy: CI_PROOF_POLICIES.release }).status, 'green'); assert.ok(!beta.proof.requiredChecks.includes('mutants'), 'мутанты не в списке обязательных — их никто не запрашивал'); // Лёгкий proof релиз по-прежнему не принимает — тяжёлые гейты обязаны быть запрошены. const light = proofFixture({ id: 31, full: false, mutants: false, backend: false, integration: false }); assert.equal(evaluateCiProof({ ...light, policy: CI_PROOF_POLICIES.release }).status, 'stale'); // Ревью и слияние без запрошенных мутантов — stale (#541 не ослаблен). for (const policy of [CI_PROOF_POLICIES.review, CI_PROOF_POLICIES.merge]) { assert.equal(policy.mutants, true, policy.name); const verdict = evaluateCiProof({ ...proofFixture({ id: 32, full: false, mutants: false, backend: false, integration: false }), policy }); assert.equal(verdict.status, 'stale', policy.name); assert.match(verdict.note, /no requested mutant jobs/); } }); test('#541 AC: green dispatch without the executed mutant jobs proves neither review nor merge', () => { const fixture = proofFixture({ full: false, backend: false, integration: false }); fixture.jobs = fixture.jobs.filter((job) => !job.name.startsWith(MUTANT_JOB_PREFIX)); for (const policy of [CI_PROOF_POLICIES.review, CI_PROOF_POLICIES.merge]) { const verdict = evaluateCiProof({ ...fixture, policy }); assert.equal(verdict.status, 'failed', policy.name); assert.match(verdict.note, /mutants: claimed execution/); } }); // #622 AC2: сколько mutant-jobs обязано исполниться — говорит матрица // validate.yml. Тот же набор job против файла с семью шардами — не // доказательство; семь job по файлу с семью — доказательство. const WORKFLOW_TEXT = readFileSync(VALIDATE_WORKFLOW_PATH, 'utf8').replace(/\r\n/g, '\n'); const withMutantShards = (count) => { const shards = Array.from({ length: count }, (_, i) => i + 1).join(', '); const text = WORKFLOW_TEXT.replace(/(\n changed_mutants:\n[\s\S]*?\n shard: )\[[^\]]*\]/, `$1[${shards}]`); assert.notEqual(text, WORKFLOW_TEXT, 'fixture must rewrite the changed_mutants matrix'); return parseWorkflowJobs(text, 'validate.yml (fixture)'); }; test('#622 AC2: the number of mutant shards comes from the validate.yml matrix, not a constant', () => { assert.equal(namesOf('changed_mutants').length, WORKFLOW.get('changed_mutants').size); const policy = CI_PROOF_POLICIES.review; const fixture = proofFixture({ full: false, backend: false, integration: false }); assert.equal(evaluateCiProof({ ...fixture, policy }).status, 'green'); const oneShardShort = { ...fixture, jobs: fixture.jobs.filter((job) => job.name !== namesOf('changed_mutants').at(-1)) }; assert.match(evaluateCiProof({ ...oneShardShort, policy }).note, /mutants: claimed execution/); const seven = withMutantShards(7); const verdict = evaluateCiProof({ ...fixture, policy, workflowJobs: seven }); assert.equal(verdict.status, 'failed', 'six jobs do not prove a seven-shard matrix'); assert.match(verdict.note, /mutants: claimed execution/); const sevenJobs = [ ...fixture.jobs.filter((job) => !job.name.startsWith(MUTANT_JOB_PREFIX)), ...jobInstanceNames(seven.get('changed_mutants')).map(success), ]; assert.equal(sevenJobs.filter((job) => job.name.startsWith(MUTANT_JOB_PREFIX)).length, 7); assert.equal(evaluateCiProof({ ...fixture, jobs: sevenJobs, policy, workflowJobs: seven }).status, 'green'); const duplicated = { ...fixture, jobs: [...fixture.jobs, success(namesOf('changed_mutants')[0])] }; assert.equal(evaluateCiProof({ ...duplicated, policy }).status, 'failed', 'an instance reported twice is ambiguous, not proof'); }); test('#622 AC1: a job renamed in validate.yml fails the proof with the named job, not as an absent execution', () => { const fixture = proofFixture(); const renamed = WORKFLOW_TEXT.replace('name: "Бэкенд: pytest в Home Assistant"', 'name: "Бэкенд: pytest в HA"'); assert.notEqual(renamed, WORKFLOW_TEXT); const workflowJobs = parseWorkflowJobs(renamed, 'validate.yml (fixture)'); // Имена job в прогоне — по тому же переименованному файлу: без сверки // контракта такой прогон прошёл бы, а rules в скриптах молча устарели. const jobs = fixture.jobs.map((job) => (job.name === names.backend ? { ...job, name: 'Бэкенд: pytest в HA' } : job)); const verdict = evaluateCiProof({ ...fixture, jobs, workflowJobs, policy: CI_PROOF_POLICIES.release }); assert.equal(verdict.status, 'failed'); assert.match(verdict.note, /job-name contract with validate\.yml is broken \(#622\): backend: validate\.yml job backend is named/); }); test('#541 AC: SHA, tree, run attempt, event and proof inventories cannot drift', () => { const fixture = proofFixture(); assert.equal(evaluateCiProof({ ...fixture, run: { ...fixture.run, attempt: 3 }, policy: CI_PROOF_POLICIES.release }).status, 'stale'); assert.equal(evaluateCiProof({ ...fixture, run: { ...fixture.run, headSha: 'd'.repeat(40) }, policy: CI_PROOF_POLICIES.release }).status, 'stale'); assert.equal(evaluateCiProof({ ...fixture, run: { ...fixture.run, event: 'push' }, policy: CI_PROOF_POLICIES.release }).status, 'stale'); const forged = structuredClone(fixture.proof); forged.executedChecks = forged.executedChecks.filter((id) => id !== 'backend'); assert.equal(evaluateCiProof({ ...fixture, proof: forged, policy: CI_PROOF_POLICIES.release }).status, 'failed'); }); test('#541 AC: reuse needs a content key, marker source SHA/run and the successful source job', () => { const fixture = proofFixture(); fixture.proof.checks.golden = { mode: 'reused', result: 'success', reuse: { key: 'e'.repeat(64), sourceRun: 77, sourceAttempt: 3, sourceSha: 'f'.repeat(40) }, }; fixture.proof.executedChecks = fixture.proof.executedChecks.filter((id) => id !== 'golden'); fixture.proof.reusedChecks = ['golden']; fixture.jobs = fixture.jobs.filter((job) => job.name !== names.golden); fixture.reuseRuns.set('77:3', { run: { id: 77, run_attempt: 3, status: 'completed', conclusion: 'failure', head_sha: 'f'.repeat(40) }, jobs: [success(names.golden)], }); assert.equal(evaluateCiProof({ ...fixture, policy: CI_PROOF_POLICIES.release }).status, 'green', 'individual green job remains lawful even when an unrelated source job made its run red'); const noSource = new Map(); assert.equal(evaluateCiProof({ ...fixture, reuseRuns: noSource, policy: CI_PROOF_POLICIES.release }).status, 'failed'); const badKey = structuredClone(fixture.proof); badKey.checks.golden.reuse.key = 'short'; assert.equal(evaluateCiProof({ ...fixture, proof: badKey, policy: CI_PROOF_POLICIES.release }).status, 'failed'); }); test('#548: geometry parity reuse is accepted only with a verified source job', () => { const fixture = proofFixture(); fixture.proof.checks.geometry_parity = { mode: 'reused', result: 'success', reuse: { key: 'e'.repeat(64), sourceRun: 78, sourceAttempt: 2, sourceSha: 'f'.repeat(40) }, }; fixture.proof.executedChecks = fixture.proof.executedChecks.filter((id) => id !== 'geometry_parity'); fixture.proof.reusedChecks = ['geometry_parity']; fixture.jobs = fixture.jobs.filter((job) => job.name !== names.geometryParity); fixture.reuseRuns.set('78:2', { run: { id: 78, run_attempt: 2, status: 'completed', conclusion: 'success', head_sha: 'f'.repeat(40) }, jobs: [success(names.geometryParity)], }); assert.equal(evaluateCiProof({ ...fixture, policy: CI_PROOF_POLICIES.release }).status, 'green'); assert.equal(evaluateCiProof({ ...fixture, reuseRuns: new Map(), policy: CI_PROOF_POLICIES.release }).status, 'failed'); }); test('#541: reuse marker parser fails closed', () => { assert.deepEqual(parseReuseMarker( `golden прогнана успешно\nSHA: ${SHA}\nпрогон: https://github.com/x/y/actions/runs/123\nпопытка: 4\n`, ), { sourceSha: SHA, sourceRun: 123, sourceAttempt: 4 }); assert.throws(() => parseReuseMarker('SHA: short\nпрогон: https://github.com/x/y/actions/runs/123\nпопытка: 1\n'), /must contain/); assert.throws(() => parseReuseMarker(`SHA: ${SHA}\n`), /must contain/); }); test('#541: uploaded deflated artifact is read without an external ZIP dependency', () => { const body = Buffer.from(JSON.stringify({ schema: 'test', ok: true })); const compressed = deflateRawSync(body); const name = Buffer.from('nested/proof.json'); const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(8, 8); local.writeUInt32LE(compressed.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(8, 10); central.writeUInt32LE(compressed.length, 20); central.writeUInt32LE(body.length, 24); central.writeUInt16LE(name.length, 28); central.writeUInt32LE(0, 42); const directoryAt = local.length + name.length + compressed.length; 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(directoryAt, 16); const zip = Buffer.concat([local, name, compressed, central, name, eocd]); assert.deepEqual(readCiProofArtifact(zip), { schema: 'test', ok: true }); }); // #573 — составное evidence. Proof называет отдельно продуктовое дерево, // overlay принятых эталонов и content-ключи реюзных job; потребитель с // checkout кандидата сверяет их, а не верит. Сценарий beta.3: кандидат C // красный только по golden, baseline-only коммит B переиспользует зелёные // job C и перегоняет golden. const KEYS = Object.fromEntries(REUSE_JOBS.map((job, index) => [job, String(index + 1).repeat(64)])); const evidenceOf = (over = {}) => ({ product: { tree: 'p'.repeat(64) }, baselines: { tree: 'c'.repeat(40), manifestSha256: 'd'.repeat(64), reviewedRun: 34853080375, reviewedLocal: null, }, keys: { ...KEYS }, ...over, }); /** B: smoke и performance_smoke reused из красного-по-golden C, golden исполнена. */ function baselineOnlyFixture() { const fixture = proofFixture({ id: 20, attempt: 1 }); const evidence = evidenceOf(); const reuseFrom = (id) => ({ mode: 'reused', result: 'success', reuse: { key: evidence.keys[id], sourceRun: 10, sourceAttempt: 1, sourceSha: 'e'.repeat(40) }, }); fixture.proof.checks.smoke = reuseFrom('smoke'); fixture.proof.checks.performance_smoke = reuseFrom('performance_smoke'); fixture.proof.checks.golden.key = evidence.keys.golden; fixture.proof.checks.geometry_parity.key = evidence.keys.geometry_parity; fixture.proof.checks.backend.key = evidence.keys.backend; fixture.proof.executedChecks = fixture.proof.executedChecks.filter((id) => !['smoke', 'performance_smoke'].includes(id)); fixture.proof.reusedChecks = ['performance_smoke', 'smoke']; fixture.proof.evidence = evidence; fixture.jobs = fixture.jobs.filter((job) => !/Смоки|Перф-смок/.test(job.name)); // C: run красный (golden different), но smoke и perf — зелёные job fixture.reuseRuns.set('10:1', { run: { id: 10, run_attempt: 1, status: 'completed', conclusion: 'failure', head_sha: 'e'.repeat(40) }, jobs: [...smokeJobs(), success(names.smokeDone), success(names.performance), { name: names.golden, conclusion: 'failure' }, ...mutantJobs()], }); fixture.reviewedRun = { run: { id: 34853080375, path: '.github/workflows/validate.yml', status: 'completed', conclusion: 'failure' } }; fixture.expected = evidenceOf(); return fixture; } test('#573 AC1: baseline-only коммит — reused smoke/perf из красного-по-golden кандидата, golden исполнена, proof green', () => { const fixture = baselineOnlyFixture(); const verdict = evaluateCiProof({ ...fixture, policy: CI_PROOF_POLICIES.release }); 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 без ожиданий: 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', () => { const fixture = baselineOnlyFixture(); fixture.reuseRuns.get('10:1').jobs = fixture.reuseRuns.get('10:1').jobs .map((job) => (job.name === namesOf('smoke')[1] ? { ...job, conclusion: 'failure' } : job)); const verdict = evaluateCiProof({ ...fixture, policy: CI_PROOF_POLICIES.release }); assert.equal(verdict.status, 'failed'); assert.match(verdict.note, /smoke: source run does not verify/); }); test('#573 AC4: подмена content-ключа, product tree, overlay, индекса или reviewed run — fail-closed', () => { const fixture = baselineOnlyFixture(); const withExpected = (over) => evaluateCiProof({ ...fixture, expected: evidenceOf(over), policy: CI_PROOF_POLICIES.release, }); assert.equal(withExpected({}).status, 'green'); assert.match(withExpected({ keys: { ...KEYS, smoke: 'f'.repeat(64) } }).note, /keys\.smoke/); assert.equal(withExpected({ keys: { ...KEYS, smoke: 'f'.repeat(64) } }).status, 'failed'); assert.match(withExpected({ product: { tree: 'q'.repeat(64) } }).note, /product\.tree/); assert.match(withExpected({ baselines: { tree: 'x'.repeat(40), manifestSha256: 'd'.repeat(64), reviewedRun: 34853080375 } }).note, /baselines\.tree/); assert.match(withExpected({ baselines: { tree: 'c'.repeat(40), manifestSha256: 'y'.repeat(64), reviewedRun: 34853080375 } }).note, /manifestSha256/); assert.match(withExpected({ baselines: { tree: 'c'.repeat(40), manifestSha256: 'd'.repeat(64), reviewedRun: 1 } }).note, /reviewedRun/); assert.match(withExpected({ baselines: { tree: 'c'.repeat(40), manifestSha256: 'd'.repeat(64), reviewedRun: 34853080375, reviewedLocal: 'a'.repeat(64), } }).note, /reviewedLocal/); // маркер реюза ссылается на ключ, отличный от ключа кандидата — внутренняя несогласованность const tampered = structuredClone(fixture.proof); tampered.checks.smoke.reuse.key = 'a'.repeat(64); assert.match(evaluateCiProof({ ...fixture, proof: tampered, policy: CI_PROOF_POLICIES.release }).note, /smoke: reused marker key differs/); // объявленный run просмотра кадров не существует / отменён / не Validate assert.match(evaluateCiProof({ ...fixture, reviewedRun: null, policy: CI_PROOF_POLICIES.release }).note, /Baseline-Reviewed run 34853080375 is missing/); assert.equal(evaluateCiProof({ ...fixture, reviewedRun: { run: { id: 34853080375, path: '.github/workflows/validate.yml', status: 'completed', conclusion: 'cancelled' } }, policy: CI_PROOF_POLICIES.release, }).status, 'failed'); assert.equal(evaluateCiProof({ ...fixture, reviewedRun: { run: { id: 34853080375, path: '.github/workflows/nightly.yml', status: 'completed', conclusion: 'success' } }, policy: CI_PROOF_POLICIES.release, }).status, 'failed'); // proof без evidence при наличии ожиданий — устарел, а не «сойдёт» const legacy = structuredClone(fixture.proof); delete legacy.evidence; 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', '100644 blob 2222\tdemo/golden/matrix.mjs', '100644 blob 3333\tdemo/golden/baselines/scene.png', '100644 blob 4444\tdemo/golden/baselines/baselines-index.json', ]; const before = productTreeId(lines.join('\n')); const accepted = productTreeId(lines.map((line) => line.replace('3333', '5555').replace('4444', '6666')).join('\n')); assert.equal(accepted, before, 'приёмка эталонов — то же продуктовое дерево'); assert.notEqual(productTreeId(lines.map((line) => line.replace('1111', '9999')).join('\n')), before); assert.notEqual(productTreeId(lines.map((line) => line.replace('2222', '9999')).join('\n')), before, 'сцены — продукт'); assert.throws(() => productTreeId(''), /empty/); assert.equal(baselineReviewedRun('Accept frames\n\nBaseline-Reviewed: https://github.com/x/y/actions/runs/777\n'), 777); assert.equal(baselineReviewedRun('no trailer'), null); assert.throws(() => baselineReviewedRun('Baseline-Reviewed: somewhere-else\n'), /does not name an actions run/); }); test('#573: buildCiProof пишет ключ исполненной реюзной job и отвергает маркер с чужим ключом', () => { const evidence = evidenceOf(); const { proof } = proofFixture({ id: 30 }); const needs = { preflight: { result: 'success' }, changes: { result: 'success', outputs: { heavy: 'true', mutants_requested: 'true', frontend: 'true', backend: 'true', geometry_parity: 'true', integration: 'true' } }, reuse: { result: 'success', outputs: { smoke: 'true', smoke_key: evidence.keys.smoke, smoke_source_run: '10', smoke_source_attempt: '1', smoke_source_sha: 'e'.repeat(40) } }, frontend: { result: 'success' }, hacs: { result: 'success' }, hassfest: { result: 'success' }, changed_mutants: { result: 'success' }, smoke: { result: 'skipped' }, smoke_done: { result: 'skipped' }, golden: { result: 'success' }, performance_smoke: { result: 'success' }, geometry_parity: { result: 'success' }, backend: { result: 'success' } }; const built = buildCiProof({ candidateSha: SHA, candidateTree: TREE, runId: 30, attempt: 1, event: 'push', needs, evidence }); assert.equal(built.checks.golden.key, evidence.keys.golden, 'исполненная job несёт свой content-ключ'); assert.equal(built.checks.smoke.mode, 'reused'); assert.deepEqual(built.evidence, evidence); assert.ok(!proof.evidence, 'без evidence блока нет — обратная совместимость записи'); const foreign = { ...needs, reuse: { ...needs.reuse, outputs: { ...needs.reuse.outputs, smoke_key: 'f'.repeat(64) } } }; assert.throws(() => buildCiProof({ candidateSha: SHA, candidateTree: TREE, runId: 30, attempt: 1, event: 'push', needs: foreign, evidence }), /smoke: reuse marker key .* differs from the candidate key/); }); test('#573: evidence живого дерева считается детерминированно и совпадает с ключами gate-reuse', () => { const root = fileURLToPath(new URL('..', import.meta.url)); const first = localEvidence(root); const second = localEvidence(root, { keys: first.keys }); assert.deepEqual(second, first); assert.match(first.product.tree, /^[0-9a-f]{64}$/); assert.match(first.baselines.tree, /^[0-9a-f]{40}$/); assert.match(first.baselines.manifestSha256, /^[0-9a-f]{64}$/); assert.equal(first.baselines.reviewedLocal, null); for (const job of REUSE_JOBS) assert.equal(first.keys[job], reuseKey(root, job)); assert.throws(() => localEvidence(root, { keys: { ...first.keys, smoke: 'f'.repeat(64) } }), /smoke: reuse job key/); });