ci: фиксировать material ночных мутаций (#549)

Issue: #549
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-13 11:21:40 +03:00
parent 5b0cb687c9
commit 723ec6d362
6 changed files with 341 additions and 50 deletions
+78 -15
View File
@@ -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 @ <SHA main>» (ревью 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, а не одно на неделю: открытое с тем же маркером в заголовке
+5 -2
View File
@@ -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`. Локально для дельты задачи —
+173 -23
View File
@@ -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}`);
}
+12
View File
@@ -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',
+48 -1
View File
@@ -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: [
+25 -9
View File
@@ -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, а не создаёт второе', () => {