mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-08 07:29:13 +00:00
ci: фиксировать material ночных мутаций (#549)
Issue: #549 User-Visible: no
This commit is contained in:
@@ -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
@@ -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`. Локально для дельты задачи —
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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, а не создаёт второе', () => {
|
||||
|
||||
Reference in New Issue
Block a user