mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Сторонние Actions закреплены SHA, права выданы по job, граница проверяется фикстурами
Три вещи, которые аудит 12.09 назвал в §10. **Перемещаемые ссылки.** `home-assistant/actions/hassfest@master` и `hacs/action@main` — это произвольный будущий коммит чужой ветки, а ревьюера с Read/Write/Bash запускал перемещаемый major `anthropics/claude-code-action@v1`. Все 116 `uses:` в девяти воркфлоу закреплены полным SHA с комментарием-версией; `scripts/action-pins.mjs` это проверяет, а предполётный вердикт Validate — исполняет. Локальная переиспользуемая workflow пина не требует и исключена явно. **Права.** Один блок `permissions` на весь конвейер выдавал `issues: write` и OIDC каждой стадии, включая единственную недоверенную — работу модели. Теперь права выдаются по job: модели только чтение и OIDC для самой `claude-code-action`, писать в issue умеют детерминированные стадии. **Граница.** Разбор запечатанного результата переехал из inline-shell в `scripts/review-result-gate.mjs` — не ради красоты, а потому что в YAML его нельзя прогнать ни одним отрицательным случаем. Проверяются те же вещи, что и раньше, и в том же объёме: точный набор файлов, контрольные суммы, схема паспорта и совпадение КАЖДОГО из семнадцати полей с тем, что посчитала детерминированная стадия. Сверху — пятнадцать враждебных фикстур: неполный набор, лишний файл, подменённое содержимое, чужой run и попытка, устаревший material_sha и tree, чужие задача, этап, раунд и ветка, вердикт вне словаря, пустой документ, manifest не о тех файлах, неразбираемый JSON. Настоящих секретов и привилегированных операций фикстуры не трогают. Issue: #556 User-Visible: no
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
// #556: перемещаемая ссылка в `uses:` — это доверие чужому владельцу тега здесь
|
||||
// и сейчас, а не коду, который читали. До правки конвейер брал
|
||||
// `home-assistant/actions/hassfest@master` и `hacs/action@main`, то есть
|
||||
// произвольный будущий коммит чужой ветки, а ревьюера с Read/Write/Bash
|
||||
// запускал перемещаемый major `anthropics/claude-code-action@v1`.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import {
|
||||
auditRepository, auditWorkflowSource, isLocal, isPinned, hasVersionNote, listWorkflows,
|
||||
} from '../scripts/action-pins.mjs';
|
||||
|
||||
test('#556: в репозитории не осталось незакреплённых Actions', () => {
|
||||
assert.deepEqual(auditRepository(), []);
|
||||
});
|
||||
|
||||
test('#556: проверка смотрит на все воркфлоу, а не на один', () => {
|
||||
const files = listWorkflows();
|
||||
assert.ok(files.length >= 9, `воркфлоу найдено ${files.length}`);
|
||||
assert.ok(files.includes('process.yml') && files.includes('validate.yml'));
|
||||
});
|
||||
|
||||
test('#556: локальная переиспользуемая workflow пина не требует', () => {
|
||||
assert.equal(isLocal('./.github/workflows/announce.yml'), true);
|
||||
assert.deepEqual(auditWorkflowSource('x.yml', ' uses: ./.github/workflows/announce.yml\n'), []);
|
||||
});
|
||||
|
||||
test('#556: перемещаемая ссылка — находка, в какой бы форме ни пришла', () => {
|
||||
for (const spec of [
|
||||
'actions/checkout@v7', 'hacs/action@main', 'home-assistant/actions/hassfest@master',
|
||||
'anthropics/claude-code-action@v1', 'owner/repo@abc1234',
|
||||
]) {
|
||||
assert.equal(isPinned(spec), false, spec);
|
||||
const found = auditWorkflowSource('x.yml', ` - uses: ${spec}\n`);
|
||||
assert.equal(found.length, 1, spec);
|
||||
assert.match(found[0], /не закреплён полным SHA/);
|
||||
}
|
||||
});
|
||||
|
||||
test('#556: SHA без комментария с версией — тоже находка', () => {
|
||||
const sha = 'a'.repeat(40);
|
||||
assert.equal(isPinned(`owner/repo@${sha}`), true);
|
||||
assert.equal(hasVersionNote(' # v7'), true);
|
||||
assert.equal(hasVersionNote(''), false);
|
||||
const bare = auditWorkflowSource('x.yml', ` - uses: owner/repo@${sha}\n`);
|
||||
assert.equal(bare.length, 1);
|
||||
assert.match(bare[0], /без комментария с версией/);
|
||||
assert.deepEqual(auditWorkflowSource('x.yml', ` - uses: owner/repo@${sha} # v7\n`), []);
|
||||
});
|
||||
|
||||
test('#556: находка называет файл и строку', () => {
|
||||
const source = ['jobs:', ' a:', ' steps:', ' - uses: hacs/action@main'].join('\n');
|
||||
assert.match(auditWorkflowSource('.github/workflows/x.yml', source)[0],
|
||||
/^\.github\/workflows\/x\.yml:4: /);
|
||||
});
|
||||
|
||||
// Проверка обязана стоять в предполётном вердикте Validate, иначе она есть, но
|
||||
// не сработает ни на одном пуше.
|
||||
test('#556: preflight Validate считает пины частью вердикта', () => {
|
||||
const workflow = readFileSync(new URL('../.github/workflows/validate.yml', import.meta.url), 'utf8');
|
||||
const preflight = workflow.slice(0, workflow.indexOf('\n changes:'));
|
||||
assert.match(preflight, /^\s+run: node scripts\/action-pins\.mjs$/m, 'шаг запускает проверку');
|
||||
assert.match(preflight, /ACTION_PINS: \$\{\{ steps\.action_pins\.outcome \}\}/);
|
||||
assert.match(preflight, /^\s+check "пины сторонних Actions" "\$ACTION_PINS"$/m,
|
||||
'исход попадает в вердикт, а не теряется в continue-on-error');
|
||||
});
|
||||
@@ -86,7 +86,7 @@ test('#540 AC3: one build, deterministic ZIP from the tree E2E installed, passpo
|
||||
assert.match(publish, /diff -u passport\/SHA256SUMS public\/SHA256SUMS/);
|
||||
assert.match(publish, /node scripts\/release-assets\.mjs check public passport\/SHA256SUMS\n/);
|
||||
assert.match(publish, /test "\$\(git rev-list -n 1 "refs\/tags\/\$TAG"\)" = "\$SHA"/);
|
||||
assert.match(publish, /download-artifact@v7/, 'the passport travels from stage as an artifact, not via the release');
|
||||
assert.match(publish, /download-artifact@/, 'the passport travels from stage as an artifact, not via the release');
|
||||
});
|
||||
|
||||
// #538: анонс — последнее звено выпуска, а не параллельное ему. Пока он висел
|
||||
|
||||
@@ -839,14 +839,19 @@ test('#551: gates, модель и интеграция имеют незави
|
||||
'модель получает exact material, а не подвижную ветку');
|
||||
|
||||
assert.match(prepare, /review-prepared-\$\{NUM\}-\$\{GITHUB_RUN_ID\}-\$\{GITHUB_RUN_ATTEMPT\}/);
|
||||
assert.equal((workflow.match(/sha256sum -c manifest\.sha256/g) || []).length, 2,
|
||||
'контракт проверяют и модель, и интеграция');
|
||||
assert.match(model, /sha256sum .*> "\$RUNNER_TEMP\/review-result\/manifest\.sha256"|sha256sum/,
|
||||
'модель запечатывает результат контрольными суммами');
|
||||
assert.match(model, /test "\$\(git rev-parse HEAD\)" = "\$MATERIAL_SHA"/);
|
||||
assert.match(model, /review-result-\$\{NUM\}-\$\{GITHUB_RUN_ID\}-\$\{GITHUB_RUN_ATTEMPT\}/);
|
||||
assert.match(integrate, /неполный или неожиданный набор evidence/);
|
||||
assert.match(integrate, /sha256sum -c manifest\.sha256/);
|
||||
assert.match(integrate, /\.run_id == \$run_id[\s\S]*\.material_sha == \$sha[\s\S]*\.material_tree == \$tree/,
|
||||
'подмена run/SHA/tree между jobs отвергается');
|
||||
// #556: разбор того же контракта переехал из inline-shell в
|
||||
// `scripts/review-result-gate.mjs` — ровно ради враждебных фикстур, которых в
|
||||
// YAML поставить было некуда (`test/review-result-gate.test.mjs`). Здесь
|
||||
// проверяется, что привилегированная стадия ходит через него и передаёт ему
|
||||
// весь паспорт, а не его часть.
|
||||
assert.match(integrate, /node scripts\/review-result-gate\.mjs --dir="\$dir"/);
|
||||
for (const field of ['MATERIAL_SHA', 'MATERIAL_TREE', 'STAGE', 'CYCLE', 'BRANCH', 'ISSUE']) {
|
||||
assert.match(integrate, new RegExp(`^\\s+${field}: `, 'm'), `${field} передаётся гейту`);
|
||||
}
|
||||
assert.match(integrate, /PREPARE_RESULT: \$\{\{ needs\.prepare\.result \}\}/);
|
||||
assert.match(integrate, /MODEL_RESULT: \$\{\{ needs\.model_review\.result \}\}/);
|
||||
assert.match(integrate, /if \[ "\$REUSE" != "true" \] && \[ "\$MODEL_RESULT" != "success" \]; then/,
|
||||
@@ -880,3 +885,28 @@ test('конвейер: ребейз не заканчивается, пока
|
||||
'ответ REST сверяется с новой вершиной');
|
||||
assert.match(rebase, /ссылка \$BRANCH за минуту не стала указывать/, 'не доехавшая ссылка — отказ, а не молчание');
|
||||
});
|
||||
|
||||
// #556: до этой правки один блок `permissions` на весь workflow выдавал
|
||||
// `issues: write` и OIDC каждой стадии, включая единственную недоверенную —
|
||||
// работу модели. Права выдаются по job и по факту использования.
|
||||
test('конвейер: права выдаются по job, модель не пишет в issue (#556)', () => {
|
||||
const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8');
|
||||
const head = workflow.slice(0, workflow.indexOf('\njobs:'));
|
||||
assert.match(head, /^permissions:\n contents: read\n/m, 'на уровне workflow остаётся только чтение');
|
||||
assert.doesNotMatch(head, /^\s+issues: write$/m, 'issues: write на весь workflow больше не выдаётся');
|
||||
assert.doesNotMatch(head, /^\s+id-token: write$/m, 'OIDC на весь workflow больше не выдаётся');
|
||||
|
||||
const jobBlock = (name, next) => workflow.slice(workflow.indexOf(`\n ${name}:\n`), workflow.indexOf(`\n ${next}:\n`));
|
||||
const model = jobBlock('model_review', 'integrate');
|
||||
assert.match(model, /^\s+permissions:\n\s+contents: read\n\s+id-token: write$/m,
|
||||
'модели — только чтение и OIDC для claude-code-action');
|
||||
assert.doesNotMatch(model.slice(0, model.indexOf('steps:')), /issues: write/,
|
||||
'модель не получает права записи в issue');
|
||||
|
||||
for (const [name, next] of [['guard', 'prepare'], ['prepare', 'model_review']]) {
|
||||
assert.match(jobBlock(name, next), /^\s+permissions:\n\s+contents: read\n\s+issues: write$/m,
|
||||
`${name} пишет в issue и только туда`);
|
||||
}
|
||||
const integrate = workflow.slice(workflow.indexOf('\n integrate:\n'));
|
||||
assert.match(integrate, /^\s+permissions:\n\s+contents: read\n\s+issues: write$/m);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// #556: враждебные фикстуры на границу «работа модели → привилегированная
|
||||
// публикация». Стадия модели — единственная недоверенная в конвейере; всё, что
|
||||
// она передаёт дальше, это один artifact, и публикация обязана принимать его
|
||||
// как ввод противника. В inline-shell эту границу нельзя было прогнать ни одним
|
||||
// отрицательным случаем — потому проверка и вынесена в функцию.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import {
|
||||
reviewResultProblems, parseManifest, sha256, REQUIRED_FILES, PASSPORT_FIELDS,
|
||||
} from '../scripts/review-result-gate.mjs';
|
||||
|
||||
const EXPECTED = Object.fromEntries(PASSPORT_FIELDS.map((f) => [f, `значение-${f}`]));
|
||||
|
||||
/** Честный artifact стадии модели. */
|
||||
function fixture(over = {}) {
|
||||
const document = over.document ?? '# разбор\n\nвсё проверено\n';
|
||||
const prepared = JSON.stringify({ schema: 1, ...EXPECTED, ...(over.prepared || {}) });
|
||||
const verdict = JSON.stringify({ verdict: 'green', high: 0, medium: 0, summary: 'ok', ...(over.verdict || {}) });
|
||||
const body = { 'review-document.md': document, 'prepared.json': prepared, 'verdict.json': verdict };
|
||||
const manifest = Object.entries(body)
|
||||
.map(([name, text]) => `${over.breakSum === name ? 'f'.repeat(64) : sha256(text)} ${name}`)
|
||||
.join('\n') + '\n';
|
||||
const files = { ...body, 'manifest.sha256': manifest, ...(over.extraFiles || {}) };
|
||||
for (const name of over.drop || []) delete files[name];
|
||||
return {
|
||||
files: Object.keys(files),
|
||||
read: (name) => files[name],
|
||||
expected: EXPECTED,
|
||||
};
|
||||
}
|
||||
|
||||
test('#556: честный результат принимается', () => {
|
||||
assert.deepEqual(reviewResultProblems(fixture()), []);
|
||||
});
|
||||
|
||||
test('#556: неполный набор файлов отвергается', () => {
|
||||
for (const name of REQUIRED_FILES) {
|
||||
const problems = reviewResultProblems(fixture({ drop: [name] }));
|
||||
assert.ok(problems.length >= 1, name);
|
||||
assert.match(problems[0], /набор файлов не тот/, name);
|
||||
}
|
||||
});
|
||||
|
||||
test('#556: лишний файл в artifact отвергается', () => {
|
||||
const problems = reviewResultProblems(fixture({ extraFiles: { 'payload.sh': 'rm -rf /' } }));
|
||||
assert.match(problems[0], /набор файлов не тот/);
|
||||
});
|
||||
|
||||
test('#556: подменённое содержимое ловится контрольной суммой', () => {
|
||||
for (const name of ['review-document.md', 'prepared.json', 'verdict.json']) {
|
||||
const problems = reviewResultProblems(fixture({ breakSum: name }));
|
||||
assert.ok(problems.some((p) => p.includes(`контрольная сумма не сходится: ${name}`)), name);
|
||||
}
|
||||
});
|
||||
|
||||
test('#556: чужой прогон и чужая попытка отвергаются', () => {
|
||||
for (const field of ['run_id', 'run_attempt']) {
|
||||
const problems = reviewResultProblems(fixture({ prepared: { [field]: 'чужое' } }));
|
||||
assert.ok(problems.some((p) => p.startsWith(`паспорт не совпал: ${field}`)), field);
|
||||
}
|
||||
});
|
||||
|
||||
test('#556: устаревший материал отвергается — и по SHA, и по дереву', () => {
|
||||
for (const field of ['material_sha', 'material_tree']) {
|
||||
const problems = reviewResultProblems(fixture({ prepared: { [field]: 'a'.repeat(40) } }));
|
||||
assert.ok(problems.some((p) => p.startsWith(`паспорт не совпал: ${field}`)), field);
|
||||
}
|
||||
});
|
||||
|
||||
test('#556: чужая задача, этап, раунд и ветка отвергаются', () => {
|
||||
for (const field of ['issue', 'stage', 'cycle', 'branch']) {
|
||||
const problems = reviewResultProblems(fixture({ prepared: { [field]: 'чужое' } }));
|
||||
assert.ok(problems.some((p) => p.startsWith(`паспорт не совпал: ${field}`)), field);
|
||||
}
|
||||
});
|
||||
|
||||
test('#556: сверяется КАЖДОЕ поле паспорта, а не выбранные', () => {
|
||||
for (const field of PASSPORT_FIELDS) {
|
||||
const problems = reviewResultProblems(fixture({ prepared: { [field]: 'подменено' } }));
|
||||
assert.ok(problems.some((p) => p.startsWith(`паспорт не совпал: ${field}`)), field);
|
||||
}
|
||||
});
|
||||
|
||||
test('#556: чужая схема паспорта отвергается', () => {
|
||||
assert.ok(reviewResultProblems(fixture({ prepared: { schema: 2 } }))
|
||||
.some((p) => p.includes('не та схема')));
|
||||
});
|
||||
|
||||
test('#556: вердикт вне словаря и кривые поля отвергаются', () => {
|
||||
assert.ok(reviewResultProblems(fixture({ verdict: { verdict: 'merged' } }))
|
||||
.some((p) => p.startsWith('verdict вне словаря')));
|
||||
assert.ok(reviewResultProblems(fixture({ verdict: { high: '0' } }))
|
||||
.some((p) => p.includes('high не число')));
|
||||
assert.ok(reviewResultProblems(fixture({ verdict: { summary: 42 } }))
|
||||
.some((p) => p.includes('summary не строка')));
|
||||
});
|
||||
|
||||
test('#556: пустой документ ревью — не документ', () => {
|
||||
assert.ok(reviewResultProblems(fixture({ document: ' \n' }))
|
||||
.some((p) => p.includes('документ ревью пуст')));
|
||||
});
|
||||
|
||||
test('#556: manifest, который покрывает не те файлы, отвергается', () => {
|
||||
const base = fixture();
|
||||
const read = (name) => (name === 'manifest.sha256'
|
||||
? `${sha256(base.read('verdict.json'))} verdict.json\n`
|
||||
: base.read(name));
|
||||
assert.ok(reviewResultProblems({ ...base, read })
|
||||
.some((p) => p.startsWith('manifest покрывает не те файлы')));
|
||||
});
|
||||
|
||||
test('#556: неразбираемые manifest и JSON — отказ, а не исключение', () => {
|
||||
const base = fixture();
|
||||
const broken = (name, text) => ({ ...base, read: (n) => (n === name ? text : base.read(n)) });
|
||||
assert.deepEqual(reviewResultProblems(broken('manifest.sha256', 'мусор')), ['manifest.sha256 не разобран']);
|
||||
assert.ok(reviewResultProblems(broken('prepared.json', '{')).some((p) => p.includes('prepared.json не разобран')));
|
||||
assert.ok(reviewResultProblems(broken('verdict.json', '{')).some((p) => p.includes('verdict.json не разобран')));
|
||||
});
|
||||
|
||||
test('#556: разбор строки sha256sum терпит и пробел, и звёздочку', () => {
|
||||
const hex = 'a'.repeat(64);
|
||||
assert.deepEqual(parseManifest(`${hex} x.md\n`), [{ hash: hex, name: 'x.md' }]);
|
||||
assert.deepEqual(parseManifest(`${hex} *x.md\n`), [{ hash: hex, name: 'x.md' }]);
|
||||
assert.equal(parseManifest('не хеш вовсе'), null);
|
||||
});
|
||||
|
||||
// Проверка обязана стоять на пути привилегированной стадии, а не просто
|
||||
// существовать в репозитории.
|
||||
test('#556: integrate пропускает artifact только через гейт', () => {
|
||||
const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8');
|
||||
const integrate = workflow.slice(workflow.indexOf('\n integrate:\n'));
|
||||
const step = integrate.slice(
|
||||
integrate.indexOf(' - name: Проверить полноту и происхождение результата'),
|
||||
integrate.indexOf(' # Ревьюер пишет только в docs/reviews/.'),
|
||||
);
|
||||
assert.ok(step.length > 0, 'шаг проверки найден');
|
||||
assert.match(step, /^\s+node scripts\/review-result-gate\.mjs --dir="\$dir"$/m);
|
||||
for (const field of PASSPORT_FIELDS) {
|
||||
if (field === 'run_id' || field === 'run_attempt') continue; // приходят из GITHUB_*
|
||||
assert.match(step, new RegExp(`^\\s+${field.toUpperCase()}: `, 'm'), `${field} передаётся гейту`);
|
||||
}
|
||||
// Публикация читает вердикт только после гейта.
|
||||
assert.ok(step.indexOf('review-result-gate.mjs') < step.indexOf('structured_output'));
|
||||
});
|
||||
@@ -71,13 +71,17 @@ test('предполётные проверки не прячут друг др
|
||||
const preflight = workflow.slice(
|
||||
workflow.indexOf('\n preflight:\n'), workflow.indexOf('\n changes:\n'),
|
||||
);
|
||||
for (const id of ['docs', 'workflow_sync', 'provenance', 'process_gate']) {
|
||||
// #556 добавил пятый сигнал — пины сторонних Actions. Число берётся из
|
||||
// списка, а не из магической константы: иначе каждая новая проверка красит
|
||||
// тест, который про другое.
|
||||
const checks = ['docs', 'workflow_sync', 'provenance', 'process_gate', 'action_pins'];
|
||||
for (const id of checks) {
|
||||
assert.ok(preflight.includes(`id: ${id}`), `нет шага ${id}`);
|
||||
assert.ok(preflight.includes(`steps.${id}.outcome`), `вердикт не читает ${id}`);
|
||||
}
|
||||
// Слияние джоб не имеет права превратить четыре независимых сигнала в один
|
||||
// Слияние джоб не имеет права превратить независимые сигналы в один
|
||||
// «первый упавший»: иначе автор узнаёт о втором нарушении следующим кругом.
|
||||
assert.equal(preflight.match(/continue-on-error: true/g)?.length, 4);
|
||||
assert.equal(preflight.match(/continue-on-error: true/g)?.length, checks.length);
|
||||
assert.ok(preflight.includes('exit $fail'), 'вердикт обязан падать сам');
|
||||
});
|
||||
|
||||
@@ -128,7 +132,7 @@ test('упавшая golden называет первопричину, а не
|
||||
|
||||
// Восстановление обязано стоять ДО прогона: после падения различить виновника
|
||||
// и свидетеля уже нечем.
|
||||
const restore = golden.indexOf('cache/restore@v6');
|
||||
const restore = golden.indexOf('cache/restore@');
|
||||
const verify = golden.indexOf('npm run golden:verify');
|
||||
assert.ok(restore > 0 && verify > restore, 'маркер падения восстанавливается до прогона');
|
||||
|
||||
@@ -443,7 +447,7 @@ test('#518: пустой план шарда не ставит окружени
|
||||
assert.match(plan, /count=\$\{count:-0\}/, 'непрочитанный план считается пустым, а не срывает шаг');
|
||||
// Дорогие шаги — под условием, но сама job исполняется: доказательство
|
||||
// гейта ревью (#510 provesMutants) требует УСПЕШНОЙ job, а не пропущенной.
|
||||
for (const step of ['run: npm ci', 'actions/setup-python@v7', 'pip install -r tests_backend/requirements.txt',
|
||||
for (const step of ['run: npm ci', 'actions/setup-python@', 'pip install -r tests_backend/requirements.txt',
|
||||
'name: Затронутые мутанты ловятся']) {
|
||||
const at = job.indexOf(step);
|
||||
assert.ok(at > 0, `нет шага ${step}`);
|
||||
@@ -466,7 +470,7 @@ test('журнал свидетелей changed_mutants: rerun продолжа
|
||||
const workflow = read('validate.yml');
|
||||
const start = workflow.indexOf('\n changed_mutants:\n');
|
||||
const job = workflow.slice(start, workflow.indexOf('\n frontend:\n', start));
|
||||
const restore = job.slice(job.indexOf('actions/cache/restore@v6'), job.indexOf('name: План шарда'));
|
||||
const restore = job.slice(job.indexOf('actions/cache/restore@'), job.indexOf('name: План шарда'));
|
||||
assert.match(restore, /key: mutation-ledger-\$\{\{ matrix\.shard \}\}-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/);
|
||||
assert.match(restore, /mutation-ledger-\$\{\{ matrix\.shard \}\}-\$\{\{ github\.run_id \}\}-/, 'rerun обязан восстановить предыдущую попытку того же run');
|
||||
assert.match(restore, /^\s+mutation-ledger-\$\{\{ matrix\.shard \}\}-\s*$/m, 'новый run обязан найти последний журнал шарда');
|
||||
@@ -474,10 +478,10 @@ test('журнал свидетелей changed_mutants: rerun продолжа
|
||||
assert.match(job, /--changed="\$BASE\.\.\$HEAD_SHA" --shard="\$SHARD\/6" \\\n\s+--ledger="artifacts\/mutation-ledger\/shard-\$SHARD\.json"/);
|
||||
// #518: журнал обязан восстанавливаться ДО плана, иначе план не увидит
|
||||
// уже пойманных и шард заплатит за окружение впустую.
|
||||
assert.ok(job.indexOf('actions/cache/restore@v6') < job.indexOf('name: План шарда'), 'restore журнала идёт до плана');
|
||||
assert.ok(job.indexOf('actions/cache/restore@') < job.indexOf('name: План шарда'), 'restore журнала идёт до плана');
|
||||
const save = job.slice(job.indexOf('name: Сохранить журнал свидетелей'));
|
||||
assert.match(save, /if: always\(\)/, 'красный или отменённый шард обязан сохранить уже пойманное');
|
||||
assert.match(save, /actions\/cache\/save@v6/);
|
||||
assert.match(save, /actions\/cache\/save@/);
|
||||
assert.match(save, /key: mutation-ledger-\$\{\{ matrix\.shard \}\}-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/);
|
||||
assert.ok(job.indexOf('name: Сохранить журнал свидетелей') > job.indexOf('--ledger='), 'save идёт после шага прогона');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user