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
This commit is contained in:
Claude
2026-09-24 10:23:28 +03:00
parent 72acafd7eb
commit baf283c50f
30 changed files with 2805 additions and 2196 deletions
+228
View File
@@ -0,0 +1,228 @@
// #623: workflow, которые GitHub исполняет из ветки по умолчанию.
//
// Для `issues`, `schedule`, `workflow_run` и ещё ряда событий GitHub берёт файл
// workflow только из `main`. До #623 тела конвейера лежали в этих файлах
// целиком, и каждая правка требовала зеркального коммита в `main`: 26 mirror-
// коммитов и 13 merge-back за месяц, а preflight сверял 3 файла из 6.
//
// Теперь в `main` живут тонкие вызывающие файлы — триггеры, run-name, права,
// concurrency, — а тело `_<имя>.yml` они вызывают по ссылке `@dev`. Этот тест
// держит проводку: каждый такой файл тонкий, вызывает своё тело из dev, передаёт
// секреты и входы, не расширяет права, а preflight сверяет ровно их.
//
// Полноценного YAML-парсера в зависимостях нет (см. scripts/workflow-jobs.mjs):
// разбирается ровно та блочная структура, которую пишут эти файлы; всё
// неожиданное — громкая ошибка разбора, а не догадка.
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
const DIR = fileURLToPath(new URL('../.github/workflows/', import.meta.url));
const read = (name) => readFileSync(join(DIR, name), 'utf8');
const OWN_REPO = 'Matysh/houseplan-card';
// События, для которых GitHub берёт workflow из ветки по умолчанию (или из базы
// PR — `pull_request_target`), а не из коммита, который их породил.
const DEFAULT_BRANCH_EVENTS = new Set([
'branch_protection_rule', 'check_run', 'check_suite', 'delete', 'discussion',
'discussion_comment', 'fork', 'gollum', 'issue_comment', 'issues', 'label',
'milestone', 'page_build', 'project', 'project_card', 'project_column', 'public',
'pull_request_target', 'registry_package', 'repository_dispatch', 'schedule',
'status', 'watch', 'workflow_run',
]);
// performance.yml по расписанию судит `main` собственным телом из `main`:
// исполняемая копия и есть та, что лежит рядом с проверяемым кодом.
const SELF_CONSISTENT = new Set(['performance.yml']);
const isComment = (line) => !line.trim() || line.trimStart().startsWith('#');
const indentOf = (line) => line.length - line.trimStart().length;
/** Верхнеуровневые блоки: ключ → строки блока без строки-заголовка. */
function topBlocks(text) {
const blocks = new Map();
let current = null;
for (const line of text.split('\n')) {
if (isComment(line) && indentOf(line) === 0) { if (current && !line.trim()) current.push(line); continue; }
const key = /^([\w-]+):(.*)$/.exec(line);
if (key && indentOf(line) === 0) {
current = [];
blocks.set(key[1], current);
if (key[2].trim() && !/^\s*#/.test(key[2])) current.push(` ${key[2].trim()}`);
continue;
}
if (current) current.push(line);
}
return blocks;
}
/** Ключи на заданном отступе внутри блока: ключ → строки под ним. */
function children(lines, indent) {
const out = new Map();
let current = null;
for (const line of lines) {
if (isComment(line)) continue;
const at = indentOf(line);
if (at < indent) break;
if (at === indent) {
const key = /^\s*([\w-]+):\s*(.*?)\s*(?:#.*)?$/.exec(line);
assert.ok(key, `не разобрана строка ${JSON.stringify(line)}`);
current = [];
out.set(key[1], current);
if (key[2]) current.value = key[2];
continue;
}
if (current) current.push(line);
}
return out;
}
/** Плоская карта `scope: level` на заданном отступе. */
function permissionMap(lines, indent) {
const map = {};
for (const [scope, rest] of children(lines, indent)) {
assert.equal(rest.length, 0, `права ${scope}: вложенность не ожидается`);
map[scope] = rest.value;
}
return map;
}
const LEVEL = { none: 0, read: 1, write: 2 };
function unionPermissions(maps) {
const out = {};
for (const map of maps) {
for (const [scope, level] of Object.entries(map)) {
assert.ok(level in LEVEL, `уровень ${scope}: ${level}`);
if (!(scope in out) || LEVEL[level] > LEVEL[out[scope]]) out[scope] = level;
}
}
return out;
}
const sorted = (map) => Object.fromEntries(Object.entries(map).sort(([a], [b]) => a.localeCompare(b)));
function triggers(name) {
const on = topBlocks(read(name)).get('on');
assert.ok(on, `${name}: нет блока on:`);
assert.ok(!on.some((line) => /^\s{2}\[/.test(line)), `${name}: on: в строку не поддерживается`);
return [...children(on, 2).keys()];
}
const workflows = readdirSync(DIR).filter((name) => /\.ya?ml$/.test(name)).sort();
const bodies = workflows.filter((name) => name.startsWith('_'));
const fromDefaultBranch = workflows
.filter((name) => !name.startsWith('_') && triggers(name).some((event) => DEFAULT_BRANCH_EVENTS.has(event)));
const THIN = fromDefaultBranch.filter((name) => !SELF_CONSISTENT.has(name));
function syncList() {
const validate = read('validate.yml');
const step = validate.slice(validate.indexOf('id: workflow_sync'));
const loop = /for file in ([^;\n]+); do/.exec(step);
assert.ok(loop, 'preflight workflow_sync перебирает список файлов');
return loop[1].trim().split(/\s+/);
}
test('#623: исполняемые из main файлы найдены по триггерам, а не по памяти', () => {
assert.deepEqual(THIN, [
'mutation-gate.yml', 'nightly.yml', 'process-metrics.yml', 'process-reconcile.yml',
'process-resume.yml', 'process.yml',
]);
for (const name of SELF_CONSISTENT) assert.ok(fromDefaultBranch.includes(name), `${name}: исключение без причины`);
});
test('#623: preflight сверяет в main и dev ровно тонкие вызывающие файлы', () => {
const list = syncList();
assert.equal(new Set(list).size, list.length, 'в списке сверки нет повторов');
assert.deepEqual([...list].sort(), THIN, 'список сверки = файлы, исполняемые из main');
});
test('#623: performance.yml из main судит main — сверка ему не нужна', () => {
const name = 'performance.yml';
const events = triggers(name).filter((event) => DEFAULT_BRANCH_EVENTS.has(event));
assert.deepEqual(events, ['schedule'], 'из ветки по умолчанию — только расписание');
const text = read(name);
assert.match(text, /push:\n\s+branches:\n\s+- main\n/);
assert.ok(!/ref: dev\b/.test(text), 'кандидат — коммит самого прогона, а не голова dev');
});
for (const name of THIN) {
const body = `_${name}`;
test(`#623: ${name} — тонкий файл, вызывающий ${body} из dev`, () => {
const caller = topBlocks(read(name));
for (const key of caller.keys()) {
assert.ok(['name', 'run-name', 'on', 'permissions', 'concurrency', 'jobs'].includes(key), `${name}: лишний ключ ${key}`);
}
const jobs = children(caller.get('jobs'), 2);
assert.equal(jobs.size, 1, `${name}: ровно одна вызывающая job`);
const [job] = jobs.values();
const keys = children(job, 4);
for (const key of keys.keys()) {
assert.ok(['name', 'if', 'permissions', 'uses', 'with', 'secrets'].includes(key), `${name}: у вызывающей job лишний ключ ${key}`);
}
assert.equal(keys.get('uses')?.value, `${OWN_REPO}/.github/workflows/${body}@dev`, `${name}: тело берётся из dev`);
assert.equal(keys.get('secrets')?.value, 'inherit', `${name}: секреты тела наследуются`);
assert.ok(existsSync(join(DIR, body)), `${body} существует`);
});
test(`#623: ${body} — только workflow_call, без run-name и concurrency уровня workflow`, () => {
const blocks = topBlocks(read(body));
assert.deepEqual(triggers(body), ['workflow_call'], `${body}: не запускается сам по себе`);
assert.ok(!blocks.has('run-name'), `${body}: run-name живёт у вызывающего`);
assert.ok(!blocks.has('concurrency'), `${body}: concurrency уровня workflow живёт у вызывающего`);
assert.ok(!/\$\{\{[^}]*\bgithub\.workflow_sha\b/.test(read(body)),
`${body}: в вызываемом workflow github.workflow_sha — SHA вызывающего из main, не тела`);
});
test(`#623: ${name} не расширяет права тела и не сужает их`, () => {
const caller = topBlocks(read(name));
const callee = topBlocks(read(body));
const callerTop = permissionMap(caller.get('permissions'), 2);
const calleeTop = permissionMap(callee.get('permissions'), 2);
assert.deepEqual(sorted(callerTop), sorted(calleeTop), 'права уровня workflow совпадают');
const effective = [...children(callee.get('jobs'), 2).values()].map((job) => {
const own = children(job, 4).get('permissions');
return own ? permissionMap(own, 6) : calleeTop;
});
const [callerJob] = children(caller.get('jobs'), 2).values();
const granted = permissionMap(children(callerJob, 4).get('permissions') || [], 6);
assert.deepEqual(sorted(granted), sorted(unionPermissions(effective)),
'потолок вызывающей job = объединение прав job тела: шире — лишнее, уже — отказ GitHub на старте');
});
test(`#623: ${name} передаёт телу каждый вход ручного запуска`, () => {
const caller = topBlocks(read(name));
const dispatch = children(caller.get('on'), 2).get('workflow_dispatch') || [];
const dispatchInputs = [...(children(dispatch, 4).get('inputs') ? children(children(dispatch, 4).get('inputs'), 6).keys() : [])];
const call = children(topBlocks(read(body)).get('on'), 2).get('workflow_call') || [];
const callInputs = [...(children(call, 4).get('inputs') ? children(children(call, 4).get('inputs'), 6).keys() : [])];
assert.deepEqual(callInputs.sort(), [...dispatchInputs].sort(), `${body}: те же входы, что у ручного запуска`);
const [callerJob] = children(caller.get('jobs'), 2).values();
const withBlock = children(callerJob, 4).get('with') || [];
const forwarded = children(withBlock, 6);
assert.deepEqual([...forwarded.keys()].sort(), [...dispatchInputs].sort(), `${name}: передаётся каждый вход`);
for (const [input, rest] of forwarded) {
assert.match(rest.value, new RegExp(`^\\$\\{\\{ inputs\\.${input}\\b`), `${name}: ${input} берётся из входа запуска`);
}
});
test(`#623: фильтр вызывающей job ${name} совпадает с фильтром тела`, () => {
const [callerJob] = children(topBlocks(read(name)).get('jobs'), 2).values();
const condition = children(callerJob, 4).get('if')?.value;
if (!condition) return;
const [first] = children(topBlocks(read(body)).get('jobs'), 2).values();
assert.equal(children(first, 4).get('if')?.value, condition,
`${name}: if повторяет стража тела — иначе посторонние события либо поднимут тело, либо потеряют запуск`);
});
}
test('#623: у каждого тела есть свой тонкий вызывающий, других вызовов нет', () => {
assert.deepEqual(bodies, THIN.map((name) => `_${name}`).sort());
for (const name of workflows) {
const calls = [...read(name).matchAll(/uses:\s*(\S*\/\.github\/workflows\/_[\w.-]+\.ya?ml@\S+|\.\/\.github\/workflows\/_[\w.-]+\.ya?ml)/g)]
.map((match) => match[1]);
const expected = THIN.includes(name) ? [`${OWN_REPO}/.github/workflows/_${name}@dev`] : [];
assert.deepEqual(calls, expected, `${name}: тела вызываются только своими тонкими файлами`);
}
});