Files
houseplan-card/test/action-pins.test.mjs
T
Claude baf283c50f ci: thin default-branch callers invoke reusable bodies at @dev (#623)
Six workflows run from the default branch (issues, schedule, workflow_run):
process, process-resume, process-reconcile, mutation-gate, nightly,
process-metrics. Their bodies move to _<name>.yml (on: workflow_call); the
original files keep only triggers, run-name, permissions, concurrency and one
job `uses: Matysh/houseplan-card/.github/workflows/_<name>.yml@dev` with
`secrets: inherit`. A pipeline change becomes one commit to dev.

- caller job permissions = union of body job permissions (#556 minimum kept
  per job inside the body); caller `if` repeats the body guard for process and
  process-resume so unrelated events stay skipped;
- dispatch inputs forwarded via workflow_call inputs of the same names;
- _mutation-gate.yml keys evidence/marker on job.workflow_sha (the body SHA):
  in a called workflow github.workflow_sha belongs to the caller in main;
- action-pins: narrow exception for this repo's _*.yml at @dev with a reason;
- preflight workflow_sync compares all six thin callers (was 3 of 6);
  performance.yml excluded: its schedule judges main with main's own body;
- tests read bodies from _*.yml; new test/default-branch-workflows.test.mjs;
  six mutants; PROCESS.md §10.4, AGENTS.md, REVIEWER.md updated.

Issue: #623
User-Visible: no
2026-09-24 10:23:28 +03:00

97 lines
5.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// #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, isOwnDevReusable, 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');
});
// #623: тонкие файлы в main вызывают тело из dev этого же репозитория. Исключение
// обязано быть узким: другой ref, другой репозиторий или файл вне `_*.yml`
// остаются находкой, иначе «@dev» стал бы дверью для любой перемещаемой ссылки.
test('#623: тело этого репозитория по ссылке @dev пина не требует, но требует причины', () => {
const own = 'Matysh/houseplan-card/.github/workflows/_process.yml@dev';
assert.equal(isOwnDevReusable(own), true);
assert.deepEqual(auditWorkflowSource('x.yml', ` uses: ${own} # #623: тело из dev\n`), []);
const bare = auditWorkflowSource('x.yml', ` uses: ${own}\n`);
assert.equal(bare.length, 1);
assert.match(bare[0], /без комментария с причиной/);
});
test('#623: исключение @dev не распространяется на другие ref, репозитории и пути', () => {
for (const spec of [
'Matysh/houseplan-card/.github/workflows/_process.yml@main',
'Matysh/houseplan-card/.github/workflows/_process.yml@issue/623-x',
'Matysh/houseplan-card/.github/workflows/process.yml@dev',
'Matysh/houseplan-card/scripts/_x.yml@dev',
'Matysh/houseplan-card-fork/.github/workflows/_process.yml@dev',
'Other/houseplan-card/.github/workflows/_process.yml@dev',
'actions/checkout@dev',
]) {
assert.equal(isOwnDevReusable(spec), false, spec);
const found = auditWorkflowSource('x.yml', ` uses: ${spec} # comment\n`);
assert.equal(found.length, 1, spec);
assert.match(found[0], /не закреплён полным SHA/, spec);
}
});