Files
houseplan-card/scripts/action-pins.mjs
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

108 lines
5.6 KiB
JavaScript
Raw Permalink 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.
#!/usr/bin/env node
/**
* Все сторонние Actions закреплены полным immutable SHA (#556).
*
* Перемещаемая ссылка в `uses:` — это доверие чужому владельцу тега здесь и
* сейчас, а не коду, который читали. До этой правки конвейер брал
* `home-assistant/actions/hassfest@master` и `hacs/action@main` — то есть
* произвольный будущий коммит чужой ветки, — а ревьюера с Read/Write/Bash
* запускал `anthropics/claude-code-action@v1`, перемещаемый major.
*
* Проверка механическая и потому не врёт: `uses:` обязан быть либо локальным
* (`./.github/…`), либо `<owner>/<repo>[/<path>]@<40 hex>` с комментарием, где
* записана человекочитаемая версия — то, что при обновлении сверяет человек.
*
* Одно исключение (#623): тело workflow этого же репозитория из ветки `dev` —
* `Matysh/houseplan-card/.github/workflows/_<имя>.yml@dev`. Для событий
* `issues`/`schedule`/`workflow_run` GitHub исполняет файл из `main`, а локальный
* `./…` взял бы тело из того же коммита `main` — и правку конвейера снова
* пришлось бы зеркалить. Это не чужой код: `dev` — наша ветка, её коммиты
* проходят тот же процессный гейт и ревью, что и всё остальное. Исключение
* узкое: только этот репозиторий, только `_*.yml` в `.github/workflows`, только
* ссылка `@dev` и только с комментарием о причине. Любой другой ref или
* репозиторий — прежняя находка.
*
* node scripts/action-pins.mjs # проверить
* node scripts/action-pins.mjs --list # что и к чему закреплено
*
* Обновление пина: посмотреть, что сейчас стоит за тегом
* `gh api repos/<owner>/<repo>/commits/<tag> -q .sha`, прочитать дельту от
* закреплённого SHA и заменить обе части — SHA и комментарий — одним коммитом.
*/
import { readFileSync, readdirSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
export const WORKFLOW_DIR = '.github/workflows';
const USES = /^\s*(?:-\s*)?uses:\s*(\S+)(.*)$/;
/** Локальная переиспользуемая workflow — не сторонний код, пина не требует. */
export const isLocal = (spec) => spec.startsWith('./');
/** #623: тело workflow этого репозитория из `dev` — наш код, а не сторонний. */
export const OWN_DEV_REUSABLE = /^Matysh\/houseplan-card\/\.github\/workflows\/_[\w.-]+\.ya?ml@dev$/;
export const isOwnDevReusable = (spec) => OWN_DEV_REUSABLE.test(spec);
export const isPinned = (spec) => /^[\w.-]+\/[\w./-]+@[0-9a-f]{40}$/.test(spec);
/** Комментарий обязателен: без него человек не знает, какую версию он закрепил. */
export const hasVersionNote = (tail) => /#\s*\S/.test(tail);
export function auditWorkflowSource(file, source) {
const problems = [];
source.split('\n').forEach((line, index) => {
const match = USES.exec(line);
if (!match) return;
const [, spec, tail] = match;
const at = `${file}:${index + 1}`;
if (isLocal(spec)) return;
if (isOwnDevReusable(spec)) {
if (!hasVersionNote(tail)) problems.push(`${at}: «${spec}» без комментария с причиной ссылки на dev`);
return;
}
if (!isPinned(spec)) {
problems.push(`${at}: «${spec}» не закреплён полным SHA`);
return;
}
if (!hasVersionNote(tail)) {
problems.push(`${at}: «${spec}» без комментария с версией`);
}
});
return problems;
}
export function listWorkflows(root = ROOT) {
return readdirSync(resolve(root, WORKFLOW_DIR))
.filter((name) => name.endsWith('.yml') || name.endsWith('.yaml'))
.sort();
}
export function auditRepository(root = ROOT) {
const problems = [];
for (const name of listWorkflows(root)) {
const file = `${WORKFLOW_DIR}/${name}`;
problems.push(...auditWorkflowSource(file, readFileSync(resolve(root, file), 'utf8')));
}
return problems;
}
const invokedDirectly = process.argv[1]
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (invokedDirectly) {
if (process.argv.includes('--list')) {
for (const name of listWorkflows()) {
const file = `${WORKFLOW_DIR}/${name}`;
for (const line of readFileSync(resolve(ROOT, file), 'utf8').split('\n')) {
const match = USES.exec(line);
if (match && !isLocal(match[1])) console.log(`${file}: ${match[1]}${match[2]}`);
}
}
process.exit(0);
}
const problems = auditRepository();
if (problems.length) {
for (const problem of problems) console.error(`::error::${problem}`);
console.error(`не закреплено: ${problems.length}`);
process.exit(1);
}
console.log('все сторонние Actions закреплены полным SHA');
}