From 723ec6d3621051c1eaa260fab469a7dcb5478398 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sun, 13 Sep 2026 11:21:40 +0300 Subject: [PATCH] =?UTF-8?q?ci:=20=D1=84=D0=B8=D0=BA=D1=81=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20material=20=D0=BD=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D0=BC=D1=83=D1=82=D0=B0=D1=86=D0=B8=D0=B9?= =?UTF-8?q?=20(#549)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: #549 User-Visible: no --- .github/workflows/mutation-gate.yml | 93 ++++++++++--- docs/TESTING.md | 7 +- scripts/mutation-gate-report.mjs | 196 ++++++++++++++++++++++++---- scripts/mutation-gate.mjs | 12 ++ test/mutation-gate-report.test.mjs | 49 ++++++- test/mutation-gate.test.mjs | 34 +++-- 6 files changed, 341 insertions(+), 50 deletions(-) diff --git a/.github/workflows/mutation-gate.yml b/.github/workflows/mutation-gate.yml index 013c48a2..bf15a931 100644 --- a/.github/workflows/mutation-gate.yml +++ b/.github/workflows/mutation-gate.yml @@ -43,8 +43,30 @@ concurrency: cancel-in-progress: true jobs: + # #549: moving ref разрешается ровно один раз. Все шарды ниже получают один + # commit/tree, а не самостоятельно читают dev в разное время. + material: + name: "Зафиксировать неизменяемый материал" + runs-on: ubuntu-latest + outputs: + sha: ${{ steps.identity.outputs.sha }} + tree: ${{ steps.identity.outputs.tree }} + ref: ${{ steps.identity.outputs.ref }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }} + fetch-depth: 0 + - name: Зафиксировать commit и tree + id: identity + run: | + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT" + echo "ref=${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}" >> "$GITHUB_OUTPUT" + mutants: name: "Мутанты: каждый обязан красить тесты (шард ${{ matrix.shard }} из 4)" + needs: material runs-on: ubuntu-latest strategy: fail-fast: false @@ -56,7 +78,7 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }} + ref: ${{ needs.material.outputs.sha }} fetch-depth: 0 - uses: actions/setup-node@v7 @@ -101,18 +123,56 @@ jobs: # `tee` не съел код выхода раннера. - name: Каждый тест ловит свою поломку run: | - mkdir -p artifacts + mkdir -p artifacts/mutation-shard-${{ matrix.shard }} set -o pipefail - node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}.log - - name: Сохранить лог шарда + node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}/mutation-shard-${{ matrix.shard }}.log + - name: Записать identity шарда + if: always() + run: | + node scripts/mutation-gate-report.mjs \ + --write-evidence=artifacts/mutation-shard-${{ matrix.shard }}/evidence.json \ + --sha=${{ needs.material.outputs.sha }} \ + --tree=${{ needs.material.outputs.tree }} \ + --workflow-sha=${{ github.sha }} \ + --run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \ + --shard=${{ matrix.shard }} --shards=4 + - name: Сохранить лог и identity шарда if: always() uses: actions/upload-artifact@v7 with: - name: mutation-shard-${{ matrix.shard }} - path: artifacts/mutation-shard-${{ matrix.shard }}.log + name: mutation-shard-${{ matrix.shard }}-attempt-${{ github.run_attempt }} + path: artifacts/mutation-shard-${{ matrix.shard }} if-no-files-found: warn retention-days: 30 + # Результат нельзя приписывать material, пока не доказаны все четыре шарда. + # always() нужен при красном мутанте: лог красного шарда всё равно evidence. + evidence: + name: "Доказать единый material всех шардов" + needs: [material, mutants] + if: always() + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.sha }} + - uses: actions/setup-node@v7 + with: + node-version: 22 + - name: Забрать evidence всех попыток + uses: actions/download-artifact@v7 + with: + pattern: mutation-shard-* + path: artifacts/mutation-logs + - name: Проверить полноту и identity + run: | + node scripts/mutation-gate-report.mjs --verify-only \ + --logs=artifacts/mutation-logs --shards=4 \ + --sha=${{ needs.material.outputs.sha }} \ + --tree=${{ needs.material.outputs.tree }} \ + --workflow-sha=${{ github.sha }} \ + --run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} + # Адресат у отказа (#472). Только по расписанию: ручной dispatch остаётся # для отладки самого гейта, его результат смотрят в прогоне — issue на # каждый такой отказ был бы шумом, который снова перестанут читать. @@ -121,8 +181,8 @@ jobs: # validate.yml, job с actions: read): перечислены все три. report: name: "Отказ расписания: issue и Telegram" - needs: mutants - if: always() && github.event_name == 'schedule' && needs.mutants.result != 'success' + needs: [material, mutants, evidence] + if: always() && github.event_name == 'schedule' && (needs.mutants.result != 'success' || needs.evidence.result != 'success') runs-on: ubuntu-latest permissions: contents: read @@ -131,11 +191,14 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: dev + # Код отчётчика берётся из того же workflow revision, а не из + # успевшего сдвинуться dev. Проверяемый material передаётся отдельно. + ref: ${{ github.sha }} - uses: actions/setup-node@v7 with: node-version: 22 - name: Забрать логи шардов + continue-on-error: true uses: actions/download-artifact@v7 with: pattern: mutation-shard-* @@ -145,14 +208,14 @@ jobs: env: RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | - # SHA — того дерева, которое чекаутили и гоняли (dev), а не - # github.sha: для расписания это вершина default-ветки main, и отчёт - # называл бы «dev @ » (ревью r1). Образец — process.yml. - SHA=$(git rev-parse HEAD) mkdir -p artifacts node scripts/mutation-gate-report.mjs \ - --logs=artifacts/mutation-logs --shards=4 \ - --run-url="$RUN_URL" --ref=dev --sha="$SHA" \ + --require-evidence --logs=artifacts/mutation-logs --shards=4 \ + --sha=${{ needs.material.outputs.sha }} \ + --tree=${{ needs.material.outputs.tree }} \ + --workflow-sha=${{ github.sha }} \ + --run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \ + --run-url="$RUN_URL" --ref=${{ needs.material.outputs.ref }} \ --body-out=artifacts/mutation-report.md \ --telegram-out=artifacts/mutation-telegram.txt >> "$GITHUB_OUTPUT" # Одно issue, а не одно на неделю: открытое с тем же маркером в заголовке diff --git a/docs/TESTING.md b/docs/TESTING.md index 5c45114e..f3905c2f 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -29,8 +29,11 @@ но не при её неработоспособности. Проверка: `node scripts/mutation-gate.mjs --check` — якоря патчей живы; -полный прогон — workflow `mutation-gate.yml` (четыре чересполосных шарда, -`--shard=i/4`) каждую ночь по расписанию (01:00 UTC); в цикле разработки и в +полный прогон — workflow `mutation-gate.yml` (четыре чересполосных шарда +`--shard=i/4`, +один зафиксированный commit/tree для всего прогона; каждый артефакт несёт +identity, а отдельный агрегатор fail-closed отвергает смешанные или неполные +evidence даже при частичном rerun) каждую ночь по расписанию (01:00 UTC); в релизном гейте он не участвует — проверяет тесты, а не продукт; отказ сам заводит issue с отчётом (#472, #513). Дешёвая половина идёт с юнитами: `test/mutation-gate.test.mjs`. Локально для дельты задачи — diff --git a/scripts/mutation-gate-report.mjs b/scripts/mutation-gate-report.mjs index 07c9cb80..b782cd16 100755 --- a/scripts/mutation-gate-report.mjs +++ b/scripts/mutation-gate-report.mjs @@ -23,15 +23,123 @@ * остальное уходит в `unparsed` с текстом как есть — потерять строку нельзя, * но и выдумывать из неё сущность тоже. */ -import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { + existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, +} from 'node:fs'; +import { dirname, join } from 'node:path'; import { isMainModule } from './spawn-portable.mjs'; export const REPORT_TITLE_MARKER = '[mutation-gate] отказ прогона по расписанию'; +export const MUTATION_EVIDENCE_SCHEMA = 'houseplan-mutation-shard-evidence/v1'; const ESCAPED_LINE = /^FAIL (\S+): тест остался зелёным на сломанном коде\s*$/; const RED_GUARD_LINE = /^FAIL чистый прогон: (.+?) красный без мутанта\s*$/; const ANY_FAIL_LINE = /^FAIL /; +const FULL_SHA = /^[0-9a-f]{40}$/; +const positiveInteger = (value) => Number.isInteger(Number(value)) && Number(value) > 0; + +/** Machine-readable identity written beside every nightly shard log (#549). */ +export function mutationShardEvidence(input) { + const evidence = { + schema: MUTATION_EVIDENCE_SCHEMA, + materialSha: String(input.materialSha || ''), + materialTree: String(input.materialTree || ''), + workflowSha: String(input.workflowSha || ''), + runId: Number(input.runId), + runAttempt: Number(input.runAttempt), + shard: Number(input.shard), + shardCount: Number(input.shardCount), + }; + if (!FULL_SHA.test(evidence.materialSha) || !FULL_SHA.test(evidence.materialTree) + || !FULL_SHA.test(evidence.workflowSha) || !positiveInteger(evidence.runId) + || !positiveInteger(evidence.runAttempt) || !positiveInteger(evidence.shard) + || !positiveInteger(evidence.shardCount) || evidence.shard > evidence.shardCount) { + throw new Error('invalid mutation shard evidence identity'); + } + return evidence; +} + +/** + * Select the newest artifact attempt for each shard, then prove that the whole + * set belongs to one immutable material and workflow run. Older artifacts are + * deliberately ignored so a full rerun may pin a fresh material, while a + * partial rerun can reuse successful shards from its earlier attempt. + */ +export function validateMutationShardEvidence(rows, expected) { + const errors = []; + const byShard = new Map(); + for (const row of rows || []) { + if (row?.error) { errors.push(`${row.file || 'evidence'}: ${row.error}`); continue; } + const evidence = row?.evidence; + try { + mutationShardEvidence(evidence || {}); + } catch { + errors.push(`${row?.file || 'evidence'}: invalid evidence identity`); + continue; + } + const shard = Number(evidence.shard); + const current = byShard.get(shard); + if (!current || Number(evidence.runAttempt) > Number(current.evidence.runAttempt)) { + byShard.set(shard, row); + } else if (Number(evidence.runAttempt) === Number(current.evidence.runAttempt)) { + errors.push(`shard ${shard}: duplicate evidence for attempt ${evidence.runAttempt}`); + } + } + + const selected = []; + const shardCount = Number(expected.shardCount); + for (let shard = 1; shard <= shardCount; shard++) { + const row = byShard.get(shard); + if (!row) { errors.push(`shard ${shard}: evidence is missing`); continue; } + const evidence = row.evidence; + selected.push(row); + if (evidence.schema !== MUTATION_EVIDENCE_SCHEMA) errors.push(`shard ${shard}: wrong schema`); + if (Number(evidence.shardCount) !== shardCount) errors.push(`shard ${shard}: wrong shard count`); + if (evidence.materialSha !== expected.materialSha) errors.push(`shard ${shard}: foreign material SHA`); + if (evidence.materialTree !== expected.materialTree) errors.push(`shard ${shard}: foreign material tree`); + if (evidence.workflowSha !== expected.workflowSha) errors.push(`shard ${shard}: foreign workflow SHA`); + if (Number(evidence.runId) !== Number(expected.runId)) errors.push(`shard ${shard}: foreign run id`); + if (!positiveInteger(evidence.runAttempt) + || Number(evidence.runAttempt) > Number(expected.runAttempt)) { + errors.push(`shard ${shard}: impossible run attempt`); + } + } + return { ok: errors.length === 0, errors, selected }; +} + +function evidenceFiles(root) { + if (!existsSync(root)) return []; + const out = []; + const walk = (dir) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) walk(path); + else if (entry.name === 'evidence.json') out.push(path); + } + }; + walk(root); + return out.sort(); +} + +export function loadMutationShardArtifacts(root, expected) { + const rows = evidenceFiles(root).map((file) => { + try { return { file, evidence: JSON.parse(readFileSync(file, 'utf8')) }; } + catch (error) { return { file, error: `invalid JSON: ${error.message}` }; } + }); + const validation = validateMutationShardEvidence(rows, expected); + const selectedByShard = new Map(validation.selected.map((row) => [Number(row.evidence.shard), row])); + const logs = []; + for (let shard = 1; shard <= Number(expected.shardCount); shard++) { + const row = selectedByShard.get(shard); + const path = row ? join(dirname(row.file), `mutation-shard-${shard}.log`) : ''; + logs.push({ shard, text: path && existsSync(path) ? readFileSync(path, 'utf8') : null }); + if (row && (!path || !existsSync(path))) validation.errors.push(`shard ${shard}: log is missing`); + } + validation.ok = validation.errors.length === 0; + return { ...validation, logs }; +} + /** * Разобрать логи шардов. * @@ -83,7 +191,8 @@ export function parseShardLogs(logs, knownIds) { export function mutationGateReport(input) { const guards = input.guards instanceof Map ? input.guards : new Map(Object.entries(input.guards || {})); const parsed = parseShardLogs(input.logs || [], [...guards.keys()]); - const failed = parsed.shards.some((s) => s.status !== 'ok') + const evidenceErrors = [...(input.evidenceErrors || [])]; + const failed = evidenceErrors.length > 0 || parsed.shards.some((s) => s.status !== 'ok') || parsed.escaped.length > 0 || parsed.redGuards.length > 0 || parsed.unparsed.length > 0; const lines = []; lines.push(`Полный мутационный прогон по расписанию не прошёл: ${input.date}, \`${input.ref}\` @ \`${String(input.sha || '').slice(0, 12)}\`.`); @@ -95,6 +204,14 @@ export function mutationGateReport(input) { const label = s.status === 'ok' ? 'ok' : s.status === 'failed' ? '**красный**' : '**артефакт не пришёл**'; lines.push(`| ${s.shard} | ${label} |`); } + if (evidenceErrors.length) { + lines.push(''); + lines.push('## Материал шардов не доказан'); + lines.push(''); + lines.push('Агрегатор отверг смешанные или неполные evidence; общий результат этому SHA не приписывается.'); + lines.push(''); + for (const error of evidenceErrors) lines.push(`- ${error}`); + } if (parsed.escaped.length) { lines.push(''); lines.push(`## Сбежавшие мутанты (${parsed.escaped.length})`); @@ -153,27 +270,60 @@ if (invokedDirectly) { const found = argv.find((item) => item.startsWith(`--${name}=`)); return found ? found.slice(name.length + 3) : fallback; }; + const writeEvidence = value('write-evidence'); const shardCount = Number(value('shards', '4')); - const dir = value('logs', 'artifacts/mutation-logs'); - const logs = []; - for (let shard = 1; shard <= shardCount; shard++) { - const path = `${dir}/mutation-shard-${shard}/mutation-shard-${shard}.log`; - logs.push({ shard, text: existsSync(path) ? readFileSync(path, 'utf8') : null }); + if (writeEvidence) { + const evidence = mutationShardEvidence({ + materialSha: value('sha'), materialTree: value('tree'), workflowSha: value('workflow-sha'), + runId: value('run-id'), runAttempt: value('run-attempt'), + shard: value('shard'), shardCount, + }); + mkdirSync(dirname(writeEvidence), { recursive: true }); + writeFileSync(writeEvidence, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8'); + console.log(`evidence=${writeEvidence}`); + } else { + const dir = value('logs', 'artifacts/mutation-logs'); + const expected = { + materialSha: value('sha'), materialTree: value('tree'), workflowSha: value('workflow-sha'), + runId: Number(value('run-id')), runAttempt: Number(value('run-attempt')), shardCount, + }; + const requireEvidence = argv.includes('--require-evidence') || argv.includes('--verify-only'); + let logs = []; + let evidenceErrors = []; + if (requireEvidence) { + const loaded = loadMutationShardArtifacts(dir, expected); + logs = loaded.logs; + evidenceErrors = loaded.errors; + for (const error of evidenceErrors) console.error(`evidence: ${error}`); + if (argv.includes('--verify-only')) { + console.log(`verified=${loaded.ok}`); + if (!loaded.ok) process.exitCode = 1; + } + } else { + for (let shard = 1; shard <= shardCount; shard++) { + const path = `${dir}/mutation-shard-${shard}/mutation-shard-${shard}.log`; + logs.push({ shard, text: existsSync(path) ? readFileSync(path, 'utf8') : null }); + } + } + if (!argv.includes('--verify-only')) { + const { MUTANTS } = await import('./mutation-gate.mjs'); + const guards = new Map(MUTANTS.map((m) => [m.id, m.guard])); + const report = mutationGateReport({ + logs, guards, evidenceErrors, + runUrl: value('run-url'), ref: value('ref', 'dev'), sha: value('sha'), + date: value('date', new Date().toISOString().slice(0, 10)), + }); + const bodyPath = value('body-out', 'artifacts/mutation-report.md'); + mkdirSync(dirname(bodyPath), { recursive: true }); + writeFileSync(bodyPath, report.body, 'utf8'); + const summaryPath = value('telegram-out', 'artifacts/mutation-telegram.txt'); + mkdirSync(dirname(summaryPath), { recursive: true }); + writeFileSync(summaryPath, telegramSummary(report, value('issue-url', '(issue)')), 'utf8'); + console.log(`title=${report.title}`); + console.log(`marker=${REPORT_TITLE_MARKER}`); + console.log(`body=${bodyPath}`); + console.log(`escaped=${report.escaped.join(',')}`); + console.log(`failed=${report.failed}`); + } } - const { MUTANTS } = await import('./mutation-gate.mjs'); - const guards = new Map(MUTANTS.map((m) => [m.id, m.guard])); - const report = mutationGateReport({ - logs, guards, - runUrl: value('run-url'), ref: value('ref', 'dev'), sha: value('sha'), - date: value('date', new Date().toISOString().slice(0, 10)), - }); - const bodyPath = value('body-out', 'artifacts/mutation-report.md'); - writeFileSync(bodyPath, report.body, 'utf8'); - const summaryPath = value('telegram-out', 'artifacts/mutation-telegram.txt'); - writeFileSync(summaryPath, telegramSummary(report, value('issue-url', '(issue)')), 'utf8'); - console.log(`title=${report.title}`); - console.log(`marker=${REPORT_TITLE_MARKER}`); - console.log(`body=${bodyPath}`); - console.log(`escaped=${report.escaped.join(',')}`); - console.log(`failed=${report.failed}`); } diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 16e98239..3a235932 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -3722,6 +3722,18 @@ const MUTANT_DEFINITIONS = [ replace: " if (asEscaped) { escaped.add(asEscaped[1]); continue; }\n if (/^FAIL (\\S+)/.test(line)) { escaped.add(line.split(' ')[1].replace(/:$/, '')); continue; }", }], }, + { + id: 'mutation-report-accepts-foreign-material', + guard: 'node --test --test-name-pattern="foreign SHA и отсутствующий шард" ' + + 'test/mutation-gate-report.test.mjs', + because: 'without the material-SHA check, a partial retry can combine a fresh shard with ' + + 'three logs from an older dev tree and publish a green result that no commit earned (#549)', + patches: [{ + file: 'scripts/mutation-gate-report.mjs', + find: ' if (evidence.materialSha !== expected.materialSha) errors.push(`shard ${shard}: foreign material SHA`);', + replace: ' if (false && evidence.materialSha !== expected.materialSha) errors.push(`shard ${shard}: foreign material SHA`);', + }], + }, // #481: журнал пойманных свидетелей — каждый защитный контракт под свидетелем. { id: 'ledger-records-escaped', diff --git a/test/mutation-gate-report.test.mjs b/test/mutation-gate-report.test.mjs index 0e65ad99..ae5c5871 100644 --- a/test/mutation-gate-report.test.mjs +++ b/test/mutation-gate-report.test.mjs @@ -1,7 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { - REPORT_TITLE_MARKER, mutationGateReport, parseShardLogs, telegramSummary, + REPORT_TITLE_MARKER, mutationGateReport, mutationShardEvidence, parseShardLogs, + telegramSummary, validateMutationShardEvidence, } from '../scripts/mutation-gate-report.mjs'; // #472. Еженедельный полный прогон падал дважды подряд, и никто не смотрел: @@ -16,6 +17,52 @@ const guards = new Map([ ['gamma-mutant', 'node --test test/g.test.mjs'], ]); const meta = { runUrl: 'https://x/runs/1', ref: 'dev', sha: 'abcdef1234567890', date: '2026-09-08' }; +const A = 'a'.repeat(40); +const B = 'b'.repeat(40); +const C = 'c'.repeat(40); +const expectedEvidence = { + materialSha: A, materialTree: B, workflowSha: C, + runId: 549, runAttempt: 2, shardCount: 4, +}; +const evidenceRow = (shard, overrides = {}) => ({ + file: `attempt-${overrides.runAttempt || 1}/shard-${shard}/evidence.json`, + evidence: mutationShardEvidence({ + ...expectedEvidence, shard, runAttempt: 1, ...overrides, + }), +}); + +test('#549: четыре шарда одного material образуют доказанный результат', () => { + const result = validateMutationShardEvidence( + [1, 2, 3, 4].map((shard) => evidenceRow(shard)), expectedEvidence, + ); + assert.equal(result.ok, true); + assert.deepEqual(result.selected.map((row) => row.evidence.shard), [1, 2, 3, 4]); +}); + +test('#549: foreign SHA и отсутствующий шард отвергаются fail-closed', () => { + const rows = [evidenceRow(1), evidenceRow(2, { materialSha: 'd'.repeat(40) }), evidenceRow(4)]; + const result = validateMutationShardEvidence(rows, expectedEvidence); + assert.equal(result.ok, false); + assert.ok(result.errors.some((error) => error.includes('foreign material SHA'))); + assert.ok(result.errors.some((error) => error.includes('shard 3: evidence is missing'))); +}); + +test('#549: partial retry выбирает новый attempt, но сохраняет единый material', () => { + const rows = [1, 2, 3, 4].map((shard) => evidenceRow(shard)); + rows.push(evidenceRow(2, { runAttempt: 2 })); + const result = validateMutationShardEvidence(rows, expectedEvidence); + assert.equal(result.ok, true); + assert.equal(result.selected.find((row) => row.evidence.shard === 2).evidence.runAttempt, 2); + assert.equal(result.selected.find((row) => row.evidence.shard === 1).evidence.runAttempt, 1); +}); + +test('#549: partial retry с другим material не склеивается со старыми шардами', () => { + const rows = [1, 2, 3, 4].map((shard) => evidenceRow(shard)); + rows.push(evidenceRow(2, { runAttempt: 2, materialSha: 'd'.repeat(40) })); + const result = validateMutationShardEvidence(rows, expectedEvidence); + assert.equal(result.ok, false); + assert.ok(result.errors.some((error) => error.includes('foreign material SHA'))); +}); test('сбежавшие собираются из нескольких шардов без дублей и по порядку (#472 AC3)', () => { const report = mutationGateReport({ ...meta, guards, logs: [ diff --git a/test/mutation-gate.test.mjs b/test/mutation-gate.test.mjs index 4db6e060..ffebd069 100644 --- a/test/mutation-gate.test.mjs +++ b/test/mutation-gate.test.mjs @@ -271,16 +271,17 @@ test('#472 AC1: у расписания и ручного запуска раз assert.match(mutationWorkflow, /group: mutation-gate-\$\{\{ github\.event_name \}\}/); }); -test('#472 AC2: каждый шард сохраняет свой лог артефактом при любом исходе', () => { +test('#472 AC2 / #549: каждый шард сохраняет лог и identity при любом исходе', () => { assert.match(mutationWorkflow, /set -o pipefail\n\s+node scripts\/mutation-gate\.mjs --shard=[^\n]*\| tee artifacts\/mutation-shard-/); - const upload = mutationWorkflow.slice(mutationWorkflow.indexOf('- name: Сохранить лог шарда')); + const upload = mutationWorkflow.slice(mutationWorkflow.indexOf('- name: Сохранить лог и identity шарда')); assert.match(upload.slice(0, 400), /if: always\(\)/); - assert.match(upload.slice(0, 400), /name: mutation-shard-\$\{\{ matrix\.shard \}\}/); + assert.match(upload.slice(0, 500), /name: mutation-shard-\$\{\{ matrix\.shard \}\}-attempt-\$\{\{ github\.run_attempt \}\}/); + assert.match(mutationWorkflow, /--write-evidence=.*evidence\.json/); }); test('#472 AC5: job report — только по расписанию, только при не-успехе, с полными правами', () => { const report = mutationWorkflow.slice(mutationWorkflow.indexOf(' report:')); - assert.match(report, /if: always\(\) && github\.event_name == 'schedule' && needs\.mutants\.result != 'success'/); + assert.match(report, /if: always\(\) && github\.event_name == 'schedule' && \(needs\.mutants\.result != 'success' \|\| needs\.evidence\.result != 'success'\)/); const permissions = report.slice(report.indexOf('permissions:'), report.indexOf('steps:')); for (const grant of ['contents: read', 'actions: read', 'issues: write']) { assert.ok(permissions.includes(grant), `нет права ${grant} у job report`); @@ -288,12 +289,27 @@ test('#472 AC5: job report — только по расписанию, толь assert.match(report, /node scripts\/mutation-gate-report\.mjs/); }); -test('#472 r1: SHA отчёта — от чекаута dev, а не github.sha (вершина main у расписания)', () => { +test('#549: moving ref фиксируется один раз, а каждый шард checkout делает по material SHA', () => { + const material = mutationWorkflow.slice(mutationWorkflow.indexOf(' material:'), mutationWorkflow.indexOf(' mutants:')); + const mutants = mutationWorkflow.slice(mutationWorkflow.indexOf(' mutants:'), mutationWorkflow.indexOf(' evidence:')); + assert.match(material, /sha: \$\{\{ steps\.identity\.outputs\.sha \}\}/); + assert.match(material, /tree: \$\{\{ steps\.identity\.outputs\.tree \}\}/); + assert.match(mutants, /needs: material/); + assert.match(mutants, /ref: \$\{\{ needs\.material\.outputs\.sha \}\}/); + assert.ok(!mutants.includes("inputs.ref || 'dev'"), 'шарды не должны независимо читать moving ref'); +}); + +test('#549: агрегатор требует четыре evidence одного material и report не перечитывает dev', () => { + const evidence = mutationWorkflow.slice(mutationWorkflow.indexOf(' evidence:'), mutationWorkflow.indexOf(' report:')); const report = mutationWorkflow.slice(mutationWorkflow.indexOf('\n report:\n')); - assert.match(report, /ref: dev/); - assert.match(report, /SHA=\$\(git rev-parse HEAD\)/); - assert.ok(!report.includes('${{ github.sha }}'), 'github.sha у schedule указывает на main, не на проверенный dev'); - assert.match(report, /--ref=dev --sha="\$SHA"/); + assert.match(evidence, /--verify-only/); + assert.match(evidence, /--sha=\$\{\{ needs\.material\.outputs\.sha \}\}/); + assert.match(evidence, /--tree=\$\{\{ needs\.material\.outputs\.tree \}\}/); + assert.match(evidence, /--run-id=\$\{\{ github\.run_id \}\} --run-attempt=\$\{\{ github\.run_attempt \}\}/); + assert.match(report, /--require-evidence/); + assert.match(report, /ref: \$\{\{ github\.sha \}\}/); + assert.ok(!report.includes('git rev-parse HEAD')); + assert.ok(!report.includes('ref: dev')); }); test('#472 AC6: повторный отказ дописывает открытое issue, а не создаёт второе', () => {