ci(mutation-gate): шесть шардов ночного прогона, прерванный шард — отказ, а не «ok» (#604)

Шард 2/4 прогона 35565222849 снят по timeout-minutes: реестр вырос до 810
мутантов (~203 на шард), длительность за 11 дней 42 → 61 мин при потолке 60.
Отчёт назвал его «ok»: лог без строк FAIL считался зелёным, а обрыв по
таймауту строк FAIL не содержит. Агрегатор проверял identity, но не
завершённость — evidence шага `if: always()` было на месте.

- mutation-gate.yml: matrix из шести шардов, `--shard=i/6`, `--shards=6`;
  шаг прогона получил id, его `outcome` пишется в evidence.
- mutation-gate-report.mjs: шард `ok` только с итоговой строкой
  `поймано N из M`, N = M, без FAIL и с исходом шага `success`; лог без
  итога или исход `cancelled`/`skipped` — `interrupted`, отказ; агрегатор
  отвергает прерванный шард как неполный. `outcome` в evidence необязателен
  ради старых артефактов, но, если назван, обязан быть из известного набора.
- тесты: обрыв → interrupted, исходы шага, evidence с outcome; делитель
  шардов один во всех местах workflow; фикстуры зелёных логов получили итог.
- мутанты: mutation-report-truncated-log-is-ok,
  mutation-evidence-ignores-cancelled-step.
- docs/TESTING.md: шесть шардов, итоговая строка.

Потолок 60 минут остаётся стражем от зависшего Chromium. Ledger в ночном
прогоне не включён: ночь гоняет всё.

Issue: #604
User-Visible: no
This commit is contained in:
Claude
2026-09-21 14:12:17 +03:00
parent 0d30dde17e
commit cc2300bf86
6 changed files with 184 additions and 24 deletions
+21 -9
View File
@@ -67,15 +67,21 @@ jobs:
echo "ref=${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}" >> "$GITHUB_OUTPUT"
mutants:
name: "Мутанты: каждый обязан красить тесты (шард ${{ matrix.shard }} из 4)"
name: "Мутанты: каждый обязан красить тесты (шард ${{ matrix.shard }} из 6)"
needs: material
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
# Шард ~64 мутантов × свой guard; бандл собирают только браузерные гварды.
# Час — потолок против зависшего Chromium.
shard: [1, 2, 3, 4, 5, 6]
# Шесть чересполосных шардов (#604): при четырёх шард нёс ~203 мутанта из
# 810 и рос с реестром — 42 мин 10.09, 57 мин 20.09, 61 мин 21.09, и шард
# 2/4 был снят по потолку без единого FAIL. Делитель тот же, что у
# `changed_mutants` в Validate; число шардов повторяется в `--shard=i/6`,
# `--shards=6` и имени job — тест `mutation-gate.test.mjs` держит их
# равными. Бандл собирают только браузерные гварды. Час — потолок против
# зависшего Chromium, а не бюджет шарда: шард, упёршийся в него, — сигнал
# снова делить, и отчёт (#472) называет такой шард прерванным, не «ok».
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -124,10 +130,14 @@ jobs:
# ЧТО сбежало. Без артефакта отказ безымянный. `PIPESTATUS` — чтобы
# `tee` не съел код выхода раннера.
- name: Каждый тест ловит свою поломку
id: gate
run: |
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 }}/mutation-shard-${{ matrix.shard }}.log
node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/6 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}/mutation-shard-${{ matrix.shard }}.log
# Исход шага едет в evidence (#604): снятый по timeout-minutes шаг даёт
# `cancelled`, и агрегатор отвергает такой шард как неполный — лог без
# строк FAIL сам по себе зелёным не считается.
- name: Записать identity шарда
if: always()
run: |
@@ -137,7 +147,8 @@ jobs:
--tree=${{ needs.material.outputs.tree }} \
--workflow-sha=${{ github.workflow_sha }} \
--run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \
--shard=${{ matrix.shard }} --shards=4
--shard=${{ matrix.shard }} --shards=6 \
--outcome=${{ steps.gate.outcome }}
- name: Сохранить лог и identity шарда
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
@@ -147,7 +158,8 @@ jobs:
if-no-files-found: warn
retention-days: 30
# Результат нельзя приписывать material, пока не доказаны все четыре шарда.
# Результат нельзя приписывать material, пока не доказаны все шесть шардов —
# каждый с identity и с дошедшим до конца прогоном (#549, #604).
# always() нужен при красном мутанте: лог красного шарда всё равно evidence.
evidence:
name: "Доказать единый material всех шардов"
@@ -171,7 +183,7 @@ jobs:
- name: Проверить полноту и identity
run: |
node scripts/mutation-gate-report.mjs --verify-only \
--logs=artifacts/mutation-logs --shards=4 \
--logs=artifacts/mutation-logs --shards=6 \
--sha=${{ needs.material.outputs.sha }} \
--tree=${{ needs.material.outputs.tree }} \
--workflow-sha=${{ github.workflow_sha }} \
@@ -214,7 +226,7 @@ jobs:
run: |
mkdir -p artifacts
node scripts/mutation-gate-report.mjs \
--require-evidence --logs=artifacts/mutation-logs --shards=4 \
--require-evidence --logs=artifacts/mutation-logs --shards=6 \
--sha=${{ needs.material.outputs.sha }} \
--tree=${{ needs.material.outputs.tree }} \
--workflow-sha=${{ github.workflow_sha }} \
+6 -4
View File
@@ -29,11 +29,13 @@
но не при её неработоспособности.
Проверка: `node scripts/mutation-gate.mjs --check` — якоря патчей живы;
полный прогон — workflow `mutation-gate.yml` (четыре чересполосных шарда
`--shard=i/4`,
полный прогон — workflow `mutation-gate.yml` (шесть чересполосных шардов
`--shard=i/6` — при четырёх шард упёрся в потолок 60 минут на 810 мутантах, #604;
один зафиксированный commit/tree для всего прогона; каждый артефакт несёт
identity, а отдельный агрегатор fail-closed отвергает смешанные или неполные
evidence даже при частичном rerun) каждую ночь по расписанию (01:00 UTC); в
identity и исход шага прогона, а отдельный агрегатор fail-closed отвергает
смешанные, неполные или прерванные по таймауту evidence даже при частичном
rerun; лог шарда считается зелёным только с итоговой строкой `поймано N из M`)
каждую ночь по расписанию (01:00 UTC); в
релизном гейте он не участвует — проверяет тесты, а не продукт; отказ сам
заводит issue с отчётом (#472, #513). Дешёвая половина
идёт с юнитами: `test/mutation-gate.test.mjs`. Локально для дельты задачи —
+63 -9
View File
@@ -38,6 +38,16 @@ const RED_GUARD_LINE = /^FAIL чистый прогон: (.+?) красный б
const UNVERIFIABLE_LINE = /^FAIL (\S+): (неприменимый мутант|ошибка подготовки до заявленного теста|прерывание инфраструктуры)\s*$/;
const CLEAN_UNVERIFIABLE_LINE = /^FAIL чистая (подготовка|инфраструктура): (.+?)\s*$/;
const ANY_FAIL_LINE = /^FAIL /;
// Итоговая строка раннера (`scripts/mutation-gate.mjs`, конец main). Лог без
// неё — обрыв: шард снят по timeout-minutes или отменён, и «FAIL не встретился»
// значит лишь «до FAIL не дошли». 21.09 (#604) шард 2/4 умер на 60-й минуте
// после ≥155 зелёных строк, и отчёт назвал его «ok».
const SUMMARY_LINE = /^поймано (\d+) из (\d+)\s*$/;
// Исходы шага GitHub Actions (`steps.<id>.outcome`), которые шард пишет в
// evidence (#604). `cancelled` — timeout job или отмена прогона; такой шард
// не завершён независимо от того, что успел попасть в лог.
export const SHARD_OUTCOMES = Object.freeze(['success', 'failure', 'cancelled', 'skipped']);
const INTERRUPTED_OUTCOMES = new Set(['cancelled', 'skipped']);
const FULL_SHA = /^[0-9a-f]{40}$/;
const positiveInteger = (value) => Number.isInteger(Number(value)) && Number(value) > 0;
@@ -54,6 +64,14 @@ export function mutationShardEvidence(input) {
shard: Number(input.shard),
shardCount: Number(input.shardCount),
};
// Исход шага прогона — необязателен ради артефактов, записанных до #604;
// если назван, обязан быть одним из известных: опечатка в workflow не должна
// превращаться в «неизвестно, значит ок».
const outcome = input.outcome == null || input.outcome === '' ? undefined : String(input.outcome);
if (outcome !== undefined) {
if (!SHARD_OUTCOMES.includes(outcome)) throw new Error(`unknown shard outcome: ${outcome}`);
evidence.outcome = outcome;
}
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)
@@ -107,6 +125,12 @@ export function validateMutationShardEvidence(rows, expected) {
|| Number(evidence.runAttempt) > Number(expected.runAttempt)) {
errors.push(`shard ${shard}: impossible run attempt`);
}
// Полнота — это не только «артефакт пришёл», но и «шард дошёл до конца»
// (#604): снятый по таймауту шаг оставляет evidence через `if: always()`,
// и без этой проверки агрегатор приписывал material результат, которого нет.
if (INTERRUPTED_OUTCOMES.has(evidence.outcome)) {
errors.push(`shard ${shard}: run was interrupted (step outcome ${evidence.outcome})`);
}
}
return { ok: errors.length === 0, errors, selected };
}
@@ -136,7 +160,11 @@ export function loadMutationShardArtifacts(root, expected) {
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 });
logs.push({
shard,
text: path && existsSync(path) ? readFileSync(path, 'utf8') : null,
outcome: row?.evidence?.outcome,
});
if (row && (!path || !existsSync(path))) validation.errors.push(`shard ${shard}: log is missing`);
}
validation.ok = validation.errors.length === 0;
@@ -146,10 +174,18 @@ export function loadMutationShardArtifacts(root, expected) {
/**
* Разобрать логи шардов.
*
* @param {Array<{ shard: number, text: string | null }>} logs — `text: null`
* означает, что артефакт шарда не пришёл. Это тоже отказ: «лога нет» не
* значит «сбежавших нет», это значит «мы не знаем».
* @param {Array<{ shard: number, text: string | null, outcome?: string }>} logs —
* `text: null` означает, что артефакт шарда не пришёл. Это тоже отказ: «лога
* нет» не значит «сбежавших нет», это значит «мы не знаем». `outcome` —
* исход шага прогона из evidence, если записан (#604).
* @param {Set<string>|string[]} knownIds — id реестра.
*
* Статусы шарда: `ok` — лог дошёл до итоговой строки `поймано N из M`, N = M,
* ни одной строки FAIL, исход шага не назван или `success`; `failed` — есть
* FAIL либо итог не сходится либо шаг красный без FAIL; `interrupted` — лог
* обрывается до итоговой строки или исход шага `cancelled`/`skipped` (таймаут,
* отмена) — тоже отказ, потому что до сбежавших могли не дойти; `missing` —
* артефакта нет.
*/
export function parseShardLogs(logs, knownIds) {
const known = new Set(knownIds);
@@ -158,11 +194,14 @@ export function parseShardLogs(logs, knownIds) {
const unverifiable = [];
const unparsed = [];
const shards = [];
for (const { shard, text } of logs) {
for (const { shard, text, outcome } of logs) {
if (text == null) { shards.push({ shard, status: 'missing' }); continue; }
let failed = false;
let summary = null;
for (const raw of String(text).split('\n')) {
const line = raw.replace(/\r$/, '');
const asSummary = SUMMARY_LINE.exec(line);
if (asSummary) { summary = { caught: Number(asSummary[1]), total: Number(asSummary[2]) }; continue; }
if (!ANY_FAIL_LINE.test(line)) continue;
failed = true;
const asEscaped = ESCAPED_LINE.exec(line);
@@ -181,7 +220,12 @@ export function parseShardLogs(logs, knownIds) {
}
unparsed.push({ shard, line });
}
shards.push({ shard, status: failed ? 'failed' : 'ok' });
let status;
if (failed) status = 'failed';
else if (INTERRUPTED_OUTCOMES.has(outcome) || !summary) status = 'interrupted';
else if (summary.caught !== summary.total || (outcome !== undefined && outcome !== 'success')) status = 'failed';
else status = 'ok';
shards.push({ shard, status });
}
return {
escaped: [...escaped].sort(),
@@ -217,7 +261,10 @@ export function mutationGateReport(input) {
lines.push('| шард | результат |');
lines.push('|---|---|');
for (const s of parsed.shards) {
const label = s.status === 'ok' ? 'ok' : s.status === 'failed' ? '**красный**' : '**артефакт не пришёл**';
const label = s.status === 'ok' ? 'ok'
: s.status === 'failed' ? '**красный**'
: s.status === 'interrupted' ? '**прерван — лог без итоговой строки (таймаут или отмена)**'
: '**артефакт не пришёл**';
lines.push(`| ${s.shard} | ${label} |`);
}
if (evidenceErrors.length) {
@@ -270,6 +317,13 @@ export function mutationGateReport(input) {
lines.push('');
lines.push(`Артефакты шардов ${missing.map((s) => s.shard).join(', ')} не пришли — это отказ, а не отсутствие сбежавших.`);
}
const interrupted = parsed.shards.filter((s) => s.status === 'interrupted');
if (interrupted.length) {
lines.push('');
lines.push(`Шарды ${interrupted.map((s) => s.shard).join(', ')} прерваны до итоговой строки \`поймано N из M\` — `
+ 'timeout-minutes job или отмена прогона (#604). Сбежавших там не разобрано: до них могли не дойти. '
+ 'Если это таймаут — реестр вырос, шардов не хватает.');
}
return {
title: `${REPORT_TITLE_MARKER}: ${input.date}`,
body: `${lines.join('\n')}\n`,
@@ -298,12 +352,12 @@ if (invokedDirectly) {
return found ? found.slice(name.length + 3) : fallback;
};
const writeEvidence = value('write-evidence');
const shardCount = Number(value('shards', '4'));
const shardCount = Number(value('shards', '6'));
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,
shard: value('shard'), shardCount, outcome: value('outcome'),
});
mkdirSync(dirname(writeEvidence), { recursive: true });
writeFileSync(writeEvidence, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');
+24
View File
@@ -3988,6 +3988,30 @@ const MUTANT_DEFINITIONS = [
replace: ' if (false && evidence.materialSha !== expected.materialSha) errors.push(`shard ${shard}: foreign material SHA`);',
}],
},
// #604: шард, снятый по таймауту, оставляет лог без строк FAIL и без итоговой
// строки; отчёт называл его «ok», а агрегатор — доказанным.
{
id: 'mutation-report-truncated-log-is-ok',
guard: 'node --test --test-name-pattern="#604: лог без итоговой строки" test/mutation-gate-report.test.mjs',
because: 'a log cut by timeout-minutes has no FAIL lines only because the run never reached '
+ 'them; calling it ok published "| 2 | ok |" for the cancelled shard of 21.09 (#604)',
patches: [{
file: 'scripts/mutation-gate-report.mjs',
find: " else if (INTERRUPTED_OUTCOMES.has(outcome) || !summary) status = 'interrupted';",
replace: " else if (INTERRUPTED_OUTCOMES.has(outcome)) status = 'interrupted'; // mutant: no summary line still ok",
}],
},
{
id: 'mutation-evidence-ignores-cancelled-step',
guard: 'node --test --test-name-pattern="#604: evidence несёт исход шага" test/mutation-gate-report.test.mjs',
because: 'the evidence job proves completeness, not just identity: a shard whose gate step '
+ 'was cancelled by the job timeout must not count towards a proven material (#604)',
patches: [{
file: 'scripts/mutation-gate-report.mjs',
find: " if (INTERRUPTED_OUTCOMES.has(evidence.outcome)) {\n errors.push(`shard ${shard}: run was interrupted (step outcome ${evidence.outcome})`);\n }",
replace: " if (false && INTERRUPTED_OUTCOMES.has(evidence.outcome)) {\n errors.push(`shard ${shard}: run was interrupted (step outcome ${evidence.outcome})`);\n }",
}],
},
// #481: журнал пойманных свидетелей — каждый защитный контракт под свидетелем.
{
id: 'ledger-records-escaped',
+45 -2
View File
@@ -68,7 +68,7 @@ test('сбежавшие собираются из нескольких шард
const report = mutationGateReport({ ...meta, guards, logs: [
{ shard: 1, text: 'ok x\nFAIL beta-mutant: тест остался зелёным на сломанном коде\n guard: node demo/smoke_b.mjs\n' },
{ shard: 2, text: 'FAIL alpha-mutant: тест остался зелёным на сломанном коде\nFAIL beta-mutant: тест остался зелёным на сломанном коде\n' },
{ shard: 3, text: 'ok gamma-mutant: тест покраснел, как обязан\n' },
{ shard: 3, text: 'ok gamma-mutant: тест покраснел, как обязан\n\nпоймано 1 из 1\n' },
] });
assert.deepEqual(report.escaped, ['alpha-mutant', 'beta-mutant']);
assert.deepEqual(report.shards.map((s) => s.status), ['failed', 'failed', 'ok']);
@@ -139,13 +139,56 @@ test('заголовок несёт постоянный маркер, тело
test('зелёный набор логов даёт failed=false (#472)', () => {
const report = mutationGateReport({ ...meta, guards, logs: [
{ shard: 1, text: 'ok alpha-mutant: тест покраснел, как обязан\n' },
{ shard: 1, text: 'ok alpha-mutant: тест покраснел, как обязан\n\nпоймано 1 из 1\n', outcome: 'success' },
{ shard: 2, text: 'поймано 3 из 3\n' },
] });
assert.equal(report.failed, false);
assert.deepEqual(report.shards.map((s) => s.status), ['ok', 'ok']);
});
// #604. Шард 2/4 ночного прогона 21.09 снят по timeout-minutes на 60-й минуте
// после ≥155 зелёных строк; строк FAIL в обрывке нет, и отчёт написал «ok» при
// красном прогоне. Зелёным считается только лог, дошедший до итоговой строки.
test('#604: лог без итоговой строки — прерван, а не «ok»', () => {
const truncated = 'ok alpha-mutant: тест покраснел, как обязан\nok beta-mutant: тест покраснел, как обязан\n';
const report = mutationGateReport({ ...meta, guards, logs: [
{ shard: 1, text: 'поймано 2 из 2\n' },
{ shard: 2, text: truncated },
] });
assert.deepEqual(report.shards.map((s) => s.status), ['ok', 'interrupted']);
assert.equal(report.failed, true, 'обрыв — отказ: до сбежавших могли не дойти');
assert.deepEqual(report.escaped, [], 'сбежавших из обрывка не выдумывается');
assert.match(report.body, /\| 2 \| \*\*прерван — лог без итоговой строки \(таймаут или отмена\)\*\* \|/);
assert.match(report.body, /Шарды 2 прерваны до итоговой строки/);
assert.match(telegramSummary(report, 'https://x/issues/9'), /2:interrupted/);
});
test('#604: исход шага cancelled прерывает шард даже при итоговой строке; failure без FAIL — красный', () => {
const finished = 'ok alpha-mutant: тест покраснел, как обязан\n\nпоймано 1 из 1\n';
const parsed = parseShardLogs([
{ shard: 1, text: finished, outcome: 'cancelled' },
{ shard: 2, text: finished, outcome: 'skipped' },
{ shard: 3, text: finished, outcome: 'failure' },
{ shard: 4, text: 'поймано 2 из 3\n', outcome: 'success' },
{ shard: 5, text: finished, outcome: 'success' },
{ shard: 6, text: finished },
], KNOWN);
assert.deepEqual(parsed.shards.map((s) => s.status), ['interrupted', 'interrupted', 'failed', 'failed', 'ok', 'ok']);
});
test('#604: evidence несёт исход шага, агрегатор отвергает прерванный шард как неполный', () => {
const rows = [1, 2, 3, 4].map((shard) => evidenceRow(shard, { outcome: shard === 2 ? 'cancelled' : 'success' }));
assert.equal(rows[1].evidence.outcome, 'cancelled');
assert.equal(evidenceRow(1).evidence.outcome, undefined, 'старые артефакты без исхода остаются валидными');
const result = validateMutationShardEvidence(rows, expectedEvidence);
assert.equal(result.ok, false);
assert.ok(result.errors.some((error) => error.includes('shard 2: run was interrupted (step outcome cancelled)')));
// красный шаг — не нарушение identity: красноту называет лог, не агрегатор
const red = validateMutationShardEvidence([1, 2, 3, 4].map((shard) => evidenceRow(shard, { outcome: 'failure' })), expectedEvidence);
assert.equal(red.ok, true);
assert.throws(() => mutationShardEvidence({ ...expectedEvidence, shard: 1, runAttempt: 1, outcome: 'timed-out' }), /unknown shard outcome/);
});
test('сводка для Telegram коротка и ведёт на issue (#472)', () => {
const report = mutationGateReport({ ...meta, guards, logs: [
{ shard: 1, text: 'FAIL alpha-mutant: тест остался зелёным на сломанном коде\n' },
+25
View File
@@ -305,6 +305,31 @@ test('#472 AC2 / #549: каждый шард сохраняет лог и identi
assert.match(mutationWorkflow, /--write-evidence=.*evidence\.json/);
});
// #604. Шард 2/4 снят по timeout-minutes: реестр вырос до 810 мутантов, ~203 на
// шард, 55–61 мин при потолке 60. Шардов шесть, и делитель повторяется в
// четырёх местах workflow — тест не даёт им разойтись при следующем делении.
test('#604: делитель шардов ночного прогона один во всех местах workflow, шардов шесть', () => {
const matrix = /shard: \[([0-9, ]+)\]/.exec(mutationWorkflow);
assert.ok(matrix, 'matrix.shard объявлена списком');
const shards = matrix[1].split(',').map((n) => Number(n.trim()));
assert.deepEqual(shards, [1, 2, 3, 4, 5, 6]);
const n = shards.length;
assert.match(mutationWorkflow, new RegExp(`\\(шард \\$\\{\\{ matrix\\.shard \\}\\} из ${n}\\)`), 'имя job');
assert.match(mutationWorkflow, new RegExp(`--shard=\\$\\{\\{ matrix\\.shard \\}\\}/${n} `), 'делитель раннера');
// Только строки команд: комментарий про `--shards=6` — не место вызова.
const shardsArgs = mutationWorkflow.split('\n').filter((line) => !/^\s*#/.test(line))
.flatMap((line) => line.match(/--shards=\d+/g) || []);
assert.equal(shardsArgs.length, 3, 'evidence шарда, агрегатор, отчёт');
assert.ok(shardsArgs.every((arg) => arg === `--shards=${n}`), shardsArgs.join(' '));
assert.ok(!/--shard=\$\{\{ matrix\.shard \}\}\/4\b|--shards=4\b|из 4\)/.test(mutationWorkflow), 'старый делитель 4 не остался');
assert.match(mutationWorkflow, /timeout-minutes: 60/, 'потолок остаётся стражем от зависшего Chromium');
});
test('#604: исход шага прогона едет в evidence шарда', () => {
assert.match(mutationWorkflow, /- name: Каждый тест ловит свою поломку\n\s+id: gate\n/);
assert.match(mutationWorkflow, /--write-evidence=[^\n]*\n(?:[^\n]*\n){5}\s+--outcome=\$\{\{ steps\.gate\.outcome \}\}/);
});
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' \|\| needs\.evidence\.result != 'success'\)/);