Files
houseplan-card/test/default-branch-workflows.test.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

229 lines
12 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.
// #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}: тела вызываются только своими тонкими файлами`);
}
});