mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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:
@@ -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}: тела вызываются только своими тонкими файлами`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user