From aef02229c6e00df73daea01afe44a279aec6b68e Mon Sep 17 00:00:00 2001 From: Matysh Date: Mon, 5 Oct 2026 11:04:29 +0300 Subject: [PATCH] fix(ci): give nightly mutation shards headroom and report cancellation accurately Split the full registry into ten shards without increasing the one-hour limit. Preserve summary and step outcome separately so cancellation after 194/194 remains red without claiming the summary was missing. Issue: #795 User-Visible: no --- .github/workflows/_mutation-gate.yml | 35 ++++++++++++------------ docs/TESTING.md | 8 ++++-- scripts/mutation-gate-report.mjs | 22 +++++++++++---- test/mutation-gate-report.test.mjs | 41 ++++++++++++++++++++++++++-- test/mutation-gate.test.mjs | 19 +++++++------ 5 files changed, 88 insertions(+), 37 deletions(-) diff --git a/.github/workflows/_mutation-gate.yml b/.github/workflows/_mutation-gate.yml index feaf0fa2..13642140 100644 --- a/.github/workflows/_mutation-gate.yml +++ b/.github/workflows/_mutation-gate.yml @@ -8,15 +8,14 @@ name: "Мутационный гейт · тело (#623)" # Прогон дорогой и проверяет не продукт, а тесты, поэтому он не входит ни в # Validate, ни в цикл разработки, ни в релизный гейт (#513, решение владельца # 09.09): каждую ночь по расписанию, отказ — issue с отчётом (#472). -# Мутанты, задетые диффом, конвейер ревью и слияние гоняют отдельно на своём -# кандидате (#510); кандидат беты, `full=true` и ночной Validate их не -# запрашивают (#601) — ночью достаточно этого полного реестра. +# С #709 ревью, слияние, кандидат беты, `full=true` и ночной Validate +# мутантов не запрашивают — ночью достаточно этого полного реестра. # Дешёвая половина — «якоря патчей живы, guard-файлы существуют» — идёт с # обычными юнитами: test/mutation-gate.test.mjs. # # #332: бандл собирается только мутантам с браузерным гвардом (guardNeedsBundle), # компиляция тестов в worktree стартует с тёплого test-build (инкрементальный -# tsc), а реестр режется на четыре чересполосных шарда — полный прогон +# tsc), а реестр режется на чересполосные шарды — полный прогон # укладывается в десятки минут вместо часов. Локальный дифф-режим: # node scripts/mutation-gate.mjs --changed origin/dev..HEAD # @@ -110,20 +109,20 @@ jobs: fi mutants: - name: "Мутанты: каждый обязан красить тесты (шард ${{ matrix.shard }} из 6)" + name: "Мутанты: каждый обязан красить тесты (шард ${{ matrix.shard }} из 10)" needs: material if: needs.material.outputs.reuse != 'true' runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: - 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` держит их - # равными. Бандл собирают только браузерные гварды. Час — потолок против + shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + # #795: ночь 05.10 (1164 мутанта) заняла 42–60 мин на шести шардах; + # четвёртый отменён по потолку. Десять шардов оставляют 116–117 мутантов + # вместо 194. Оценка по длительностям той ночи — максимум около 39 мин + # с запасом на setup; это прогноз, фактическое время проверяет следующая ночь. + # Матрицу, делители runner/evidence и имя job держит mutation-gate.test.mjs. + # Бандл собирают только браузерные гварды. Час — потолок против # зависшего Chromium, а не бюджет шарда: шард, упёршийся в него, — сигнал # снова делить, и отчёт (#472) называет такой шард прерванным, не «ok». timeout-minutes: 60 @@ -178,7 +177,7 @@ jobs: run: | mkdir -p artifacts/mutation-shard-${{ matrix.shard }} set -o pipefail - node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/6 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}/mutation-shard-${{ matrix.shard }}.log + node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/10 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}/mutation-shard-${{ matrix.shard }}.log # Исход шага едет в evidence (#604): снятый по timeout-minutes шаг даёт # `cancelled`, и агрегатор отвергает такой шард как неполный — лог без # строк FAIL сам по себе зелёным не считается. @@ -191,7 +190,7 @@ jobs: --tree=${{ needs.material.outputs.tree }} \ --workflow-sha=${{ job.workflow_sha }} \ --run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \ - --shard=${{ matrix.shard }} --shards=6 \ + --shard=${{ matrix.shard }} --shards=10 \ --outcome=${{ steps.gate.outcome }} - name: Сохранить лог и identity шарда if: always() @@ -202,7 +201,7 @@ jobs: if-no-files-found: warn retention-days: 30 - # Результат нельзя приписывать material, пока не доказаны все шесть шардов — + # Результат нельзя приписывать material, пока не доказаны все десять шардов — # каждый с identity и с дошедшим до конца прогоном (#549, #604). # always() нужен при красном мутанте: лог красного шарда всё равно evidence. evidence: @@ -228,13 +227,13 @@ jobs: - name: Проверить полноту и identity run: | node scripts/mutation-gate-report.mjs --verify-only \ - --logs=artifacts/mutation-logs --shards=6 \ + --logs=artifacts/mutation-logs --shards=10 \ --sha=${{ needs.material.outputs.sha }} \ --tree=${{ needs.material.outputs.tree }} \ --workflow-sha=${{ job.workflow_sha }} \ --run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} - # #620: маркер пишется ТОЛЬКО после зелёного агрегатора — все шесть шардов + # #620: маркер пишется ТОЛЬКО после зелёного агрегатора — все десять шардов # доказаны на одном material. Красный или неполный прогон маркера не оставляет, # и следующая ночь гонит реестр заново. green_marker: @@ -306,7 +305,7 @@ jobs: run: | mkdir -p artifacts node scripts/mutation-gate-report.mjs \ - --require-evidence --logs=artifacts/mutation-logs --shards=6 \ + --require-evidence --logs=artifacts/mutation-logs --shards=10 \ --sha=${{ needs.material.outputs.sha }} \ --tree=${{ needs.material.outputs.tree }} \ --workflow-sha=${{ job.workflow_sha }} \ diff --git a/docs/TESTING.md b/docs/TESTING.md index a0b24c7e..fc733ad8 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -46,13 +46,15 @@ golden, а то, чего автоматика не видит, собрано `no-new-private-writes`, остальное — ревью (раздел ниже). Проверка: `node scripts/mutation-gate.mjs --check` — якоря патчей живы; -полный прогон — workflow `mutation-gate.yml` (шесть чересполосных шардов -`--shard=i/6` — при четырёх шард упёрся в потолок 60 минут, #604; число мутантов +полный прогон — workflow `mutation-gate.yml` (десять чересполосных шардов +`--shard=i/10` — при четырёх, а затем и шести шардах реестр упёрся в потолок +60 минут, #604/#795; число мутантов считает `npm run inventory`, а не этот текст; один зафиксированный commit/tree для всего прогона; каждый артефакт несёт identity и исход шага прогона, а отдельный агрегатор fail-closed отвергает смешанные, неполные или прерванные по таймауту evidence даже при частичном -rerun; лог шарда считается зелёным только с итоговой строкой `поймано N из M`) +rerun; лог шарда считается зелёным только с итоговой строкой `поймано N из M` +и успешным исходом шага — отмена даже после итогов остаётся отказом) каждую ночь по расписанию (00:43 UTC); в релизном гейте он не участвует — проверяет тесты, а не продукт; отказ сам заводит issue с отчётом (#472, #513). С #620 ночь по расписанию на дереве, уже diff --git a/scripts/mutation-gate-report.mjs b/scripts/mutation-gate-report.mjs index 3fab2674..5fad42b0 100755 --- a/scripts/mutation-gate-report.mjs +++ b/scripts/mutation-gate-report.mjs @@ -225,7 +225,9 @@ export function parseShardLogs(logs, knownIds) { 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 }); + // #795: отмена может прийти ПОСЛЕ итоговой строки. Сохраняем оба факта: + // итог не превращает cancelled в успех, отмена не стирает итог из отчёта. + shards.push({ shard, status, outcome, summary }); } return { escaped: [...escaped].sort(), @@ -263,7 +265,7 @@ export function mutationGateReport(input) { for (const s of parsed.shards) { const label = s.status === 'ok' ? 'ok' : s.status === 'failed' ? '**красный**' - : s.status === 'interrupted' ? '**прерван — лог без итоговой строки (таймаут или отмена)**' + : s.status === 'interrupted' ? interruptedLabel(s) : '**артефакт не пришёл**'; lines.push(`| ${s.shard} | ${label} |`); } @@ -320,9 +322,8 @@ export function mutationGateReport(input) { 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). Сбежавших там не разобрано: до них могли не дойти. ' - + 'Если это таймаут — реестр вырос, шардов не хватает.'); + lines.push('Прерванные шарды не дают полного доказательства: итоговая строка не заменяет успешный исход шага. ' + + 'Причину cancelled (таймаут или ручная отмена) уточняют аннотации job; один лог её не доказывает.'); } return { title: `${REPORT_TITLE_MARKER}: ${input.date}`, @@ -332,6 +333,15 @@ export function mutationGateReport(input) { }; } +function interruptedLabel(shard) { + const outcome = shard.outcome === 'cancelled' ? 'шаг cancelled (таймаут или отмена); ' + : shard.outcome === 'skipped' ? 'шаг skipped; ' : ''; + const detail = shard.summary + ? `итог в логе ${shard.summary.caught}/${shard.summary.total} не подтверждает успешное завершение` + : 'лог без итоговой строки; успешное завершение не доказано'; + return `**прерван — ${outcome}${detail}**`; +} + /** Короткий текст для Telegram: заголовок, сбежавшие, ссылка. */ export function telegramSummary(report, issueUrl) { const head = `⛔ houseplan-card: ${REPORT_TITLE_MARKER}`; @@ -352,7 +362,7 @@ if (invokedDirectly) { return found ? found.slice(name.length + 3) : fallback; }; const writeEvidence = value('write-evidence'); - const shardCount = Number(value('shards', '6')); + const shardCount = Number(value('shards', '10')); if (writeEvidence) { const evidence = mutationShardEvidence({ materialSha: value('sha'), materialTree: value('tree'), workflowSha: value('workflow-sha'), diff --git a/test/mutation-gate-report.test.mjs b/test/mutation-gate-report.test.mjs index db30c2e7..d9e68667 100644 --- a/test/mutation-gate-report.test.mjs +++ b/test/mutation-gate-report.test.mjs @@ -158,8 +158,8 @@ test('#604: лог без итоговой строки — прерван, а 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(report.body, /\| 2 \| \*\*прерван — лог без итоговой строки; успешное завершение не доказано\*\* \|/); + assert.match(report.body, /Прерванные шарды не дают полного доказательства/); assert.match(telegramSummary(report, 'https://x/issues/9'), /2:interrupted/); }); @@ -176,6 +176,43 @@ test('#604: исход шага cancelled прерывает шард даже assert.deepEqual(parsed.shards.map((s) => s.status), ['interrupted', 'interrupted', 'failed', 'failed', 'ok', 'ok']); }); +test('#795: cancelled с итогами 194/194 остаётся отказом с точной причиной', () => { + const report = mutationGateReport({ ...meta, guards, logs: [ + { shard: 4, text: 'поймано 194 из 194\n', outcome: 'cancelled' }, + ] }); + assert.equal(report.failed, true); + assert.equal(report.shards[0].status, 'interrupted'); + assert.match(report.body, /шаг cancelled \(таймаут или отмена\)/); + assert.match(report.body, /итог в логе 194\/194 не подтверждает успешное завершение/); + assert.doesNotMatch(report.body, /без итоговой строки|до итоговой строки|Сбежавших там не разобрано/); + assert.match(telegramSummary(report, 'https://x/issues/795'), /4:interrupted/); +}); + +test('#795: причины прерывания не смешивают отмену, пропуск и отсутствие итогов', () => { + for (const [outcome, text, expected] of [ + ['cancelled', 'ok alpha-mutant: заявленный тест покраснел на мутанте\n', /шаг cancelled \(таймаут или отмена\); лог без итоговой строки/], + ['skipped', 'поймано 3 из 3\n', /шаг skipped; итог в логе 3\/3 не подтверждает успешное завершение/], + ['success', '', /лог без итоговой строки; успешное завершение не доказано/], + ]) { + const report = mutationGateReport({ ...meta, guards, logs: [{ shard: 1, text, outcome }] }); + assert.equal(report.failed, true); + assert.match(report.body, expected); + if (outcome !== 'cancelled') assert.doesNotMatch(report.body, /таймаут или отмена/); + } +}); + +test('#795: десять evidence обязательны — девять или чужой десятый не доказывают весь реестр', () => { + const expected = { ...expectedEvidence, shardCount: 10 }; + const rows = Array.from({ length: 10 }, (_, i) => evidenceRow(i + 1, { shardCount: 10, outcome: 'success' })); + assert.equal(validateMutationShardEvidence(rows, expected).ok, true); + const missing = validateMutationShardEvidence(rows.slice(0, 9), expected); + assert.equal(missing.ok, false); + assert.ok(missing.errors.includes('shard 10: evidence is missing')); + const foreign = validateMutationShardEvidence([...rows.slice(0, 9), evidenceRow(10, { shardCount: 10, materialSha: 'd'.repeat(40) })], expected); + assert.equal(foreign.ok, false); + assert.ok(foreign.errors.includes('shard 10: foreign material SHA')); +}); + 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'); diff --git a/test/mutation-gate.test.mjs b/test/mutation-gate.test.mjs index 371df305..beb5e004 100644 --- a/test/mutation-gate.test.mjs +++ b/test/mutation-gate.test.mjs @@ -442,23 +442,26 @@ 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, шардов шесть', () => { +// #795: на шести шардах ночь снова дошла до часового потолка. Контракт +// проверяет не только одинаковые делители, но и полный охват реальной раскладкой. +test('#795: десять ночных шардов согласованы с runner/evidence и покрывают реестр ровно один раз', () => { 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]); + assert.deepEqual(shards, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); 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 не остался'); + const scheduled = shards.flatMap((shard) => shardMutants(MUTANTS, shard, n).map((m) => m.id)); + assert.deepEqual(scheduled.sort(), MUTANTS.map((m) => m.id).sort(), 'нет пропусков и повторов'); + for (const shard of shards) { + assert.ok(shardMutants(MUTANTS, shard, n).length <= Math.ceil(MUTANTS.length / n)); + } assert.match(mutationWorkflow, /timeout-minutes: 60/, 'потолок остаётся стражем от зависшего Chromium'); }); @@ -488,7 +491,7 @@ test('#549: moving ref фиксируется один раз, а каждый assert.ok(!mutants.includes("inputs.ref || 'dev'"), 'шарды не должны независимо читать moving ref'); }); -test('#549: агрегатор требует четыре evidence одного material и report не перечитывает dev', () => { +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(evidence, /--verify-only/);