ci: имена job validate.yml — контракт, сверяемый с файлом; шарды из матрицы (#622)

- scripts/workflow-jobs.mjs: разбор job-уровня validate.yml (id, name,
  inline matrix) без зависимостей; незнакомые формы — громкая ошибка.
- ci-proof: JOB_RULES по id job с именем-контрактом; число и точные имена
  экземпляров — из матрицы YAML (константы count: 6 нет); resolveJobRules
  на каждом evaluateCiProof — переименование даёт failed с названной job;
  jobContractProblems — сверка в обе стороны, UNCONSUMED_JOBS для proof.
- validate-gate: MUTANT_JOB_PREFIX — из контракта ci-proof.
- e2e-gate: E2E_JOB_NAME — зеркало name: из houseplan-e2e e2e.yml
  (43899da5), распознавание выводится из шаблона; missing называет
  завершённые прогоны без job по контракту.
- тесты: фикстуры имён выводятся из validate.yml; новый
  test/workflow-jobs.test.mjs; 5 мутантов в реестре.

Issue: #622
User-Visible: no
This commit is contained in:
Claude
2026-09-24 01:35:25 +00:00
committed by claude[bot]
parent 0ca56b4450
commit 1514899746
12 changed files with 593 additions and 93 deletions
+97 -27
View File
@@ -11,6 +11,7 @@ import { dirname, resolve } from 'node:path';
import { isMainModule } from './spawn-portable.mjs';
import { BASELINE_OVERLAY, REUSE_JOBS, globToRegExp } from './check-inputs.mjs';
import { reuseKey } from './gate-reuse.mjs';
import { jobInstanceNames, staticNamePrefix, validateJobs } from './workflow-jobs.mjs';
export const CI_PROOF_SCHEMA = 'houseplan-ci-proof/v1';
export const CI_PROOF_ARTIFACT_PREFIX = 'ci-proof';
@@ -18,8 +19,9 @@ export const CI_PROOF_STATES = Object.freeze([
'green', 'missing', 'pending', 'cancelled', 'stale', 'failed',
]);
// Мутанты по диффу — доказательство для ревью и слияния (#510): без шести
// исполненных mutant-jobs ни то ни другое не разрешается. Релиз их не
// Мутанты по диффу — доказательство для ревью и слияния (#510): без всех
// исполненных mutant-jobs (сколько их — говорит матрица validate.yml, #622)
// ни то ни другое не разрешается. Релиз их не
// требует (#601): к кандидату беты каждая задача прогнана ими на ревью и на
// слитом кандидате, а `Release:` мутантов больше не запрашивает — политика
// с `mutants: true` объявляла бы каждый кандидат беты `stale`.
@@ -29,26 +31,87 @@ export const CI_PROOF_POLICIES = Object.freeze({
release: Object.freeze({ name: 'release', full: true, mutants: false }),
});
const JOB_RULES = Object.freeze({
preflight: [{ exact: 'Предполёт: документация, провенанс, процесс', count: 1 }],
changes: [{ exact: 'Классификация изменённых файлов', count: 1 }],
reuse: [{ exact: 'Переиспользование: это дерево уже проверено', count: 1 }],
frontend: [{ exact: 'Фронтенд: типы, юниты, мутанты, синхрон бандла', count: 1 }],
// #622: какие job `validate.yml` доказывают проверку. `name` — контракт имени
// (у матричной job — неизменная часть до `${{`); его сверяет с YAML
// test/workflow-jobs.test.mjs, а здесь — resolveJobRules на каждом вызове
// evaluateCiProof. Сколько экземпляров и как точно они названы, читается из
// самого validate.yml (`strategy.matrix`): константа `count: 6` жила отдельно
// от матрицы и при смене шардов молча давала «claimed execution is absent».
export const JOB_RULES = Object.freeze({
preflight: [{ job: 'preflight', name: 'Предполёт: документация, провенанс, процесс' }],
changes: [{ job: 'changes', name: 'Классификация изменённых файлов' }],
reuse: [{ job: 'reuse', name: 'Переиспользование: это дерево уже проверено' }],
frontend: [{ job: 'frontend', name: 'Фронтенд: типы, юниты, мутанты, синхрон бандла' }],
integration: [
{ exact: 'HACS: валидация репозитория', count: 1 },
{ exact: 'Hassfest: манифест интеграции', count: 1 },
{ job: 'hacs', name: 'HACS: валидация репозитория' },
{ job: 'hassfest', name: 'Hassfest: манифест интеграции' },
],
mutants: [{ prefix: 'Мутанты по диффу (', count: 6 }],
mutants: [{ job: 'changed_mutants', name: 'Мутанты по диффу (' }],
smoke: [
{ prefix: 'Смоки в браузере (шард ', count: 3 },
{ exact: 'Смоки: все шарды зелёные', count: 1 },
{ job: 'smoke', name: 'Смоки в браузере (шард ' },
{ job: 'smoke_done', name: 'Смоки: все шарды зелёные' },
],
golden: [{ exact: 'Golden-кадры против принятых эталонов', count: 1 }],
performance_smoke: [{ exact: 'Перф-смок: бюджет времени кадра', count: 1 }],
geometry_parity: [{ exact: 'Геометрия: TS/Python parity исполнена', count: 1 }],
backend: [{ exact: 'Бэкенд: pytest в Home Assistant', count: 1 }],
golden: [{ job: 'golden', name: 'Golden-кадры против принятых эталонов' }],
performance_smoke: [{ job: 'performance_smoke', name: 'Перф-смок: бюджет времени кадра' }],
geometry_parity: [{ job: 'geometry_parity', name: 'Геометрия: TS/Python parity исполнена' }],
backend: [{ job: 'backend', name: 'Бэкенд: pytest в Home Assistant' }],
});
/**
* Job validate.yml, чьё имя ни одно правило не читает. Записаны с именем,
* чтобы контрактный тест видел ВСЕ job в обе стороны (#622 AC1): новая job
* или переименование любой — решение, а не тихое расхождение.
*/
export const UNCONSUMED_JOBS = Object.freeze({ proof: 'Доказательство выполненных проверок' });
/** Общий префикс имён mutant-jobs — единственный источник для validate-gate. */
export const MUTANT_JOB_PREFIX = JOB_RULES.mutants[0].name;
/**
* Правила с точными именами экземпляров из validate.yml. Расхождение
* контракта с файлом — ошибка с названной job, а не пустое совпадение:
* иначе ревью, слияние и релиз узнали бы о переименовании как о
* «claimed execution is absent» (#622).
*/
export function resolveJobRules(workflowJobs = validateJobs()) {
return Object.fromEntries(Object.entries(JOB_RULES).map(([id, rules]) => [id, rules.map((rule) => {
const job = workflowJobs.get(rule.job);
if (!job) throw new Error(`${id}: validate.yml has no job ${rule.job}`);
const actual = job.matrix ? staticNamePrefix(job.name) : job.name;
if (actual !== rule.name) {
throw new Error(`${id}: validate.yml job ${rule.job} is named ${JSON.stringify(job.name)}, ci-proof expects ${JSON.stringify(rule.name)}${job.matrix ? ' as its prefix' : ''}`);
}
return { job: rule.job, names: jobInstanceNames(job) };
})]));
}
/**
* Все расхождения контракта имён с validate.yml — в обе стороны (#622 AC1):
* правило без job, job с другим именем, job без записи. Пустой список —
* контракт цел. Рантайм сверяет только читаемые job (resolveJobRules), тест —
* все.
*/
export function jobContractProblems(workflowJobs = validateJobs()) {
const problems = [];
const declared = new Map();
const declare = (job, name, owner) => {
if (declared.has(job)) problems.push(`${job}: declared twice (${declared.get(job).owner}, ${owner})`);
declared.set(job, { name, owner });
};
for (const [id, rules] of Object.entries(JOB_RULES)) for (const rule of rules) declare(rule.job, rule.name, `JOB_RULES.${id}`);
for (const [job, name] of Object.entries(UNCONSUMED_JOBS)) declare(job, name, 'UNCONSUMED_JOBS');
for (const [job, { name, owner }] of declared) {
const entry = workflowJobs.get(job);
if (!entry) { problems.push(`${job}: ${owner} names a job that validate.yml does not have`); continue; }
const actual = entry.matrix ? staticNamePrefix(entry.name) : entry.name;
if (actual !== name) problems.push(`${job}: validate.yml names it ${JSON.stringify(entry.name)}, ${owner} expects ${JSON.stringify(name)}${entry.matrix ? ' as its prefix' : ''}`);
}
for (const job of workflowJobs.keys()) {
if (!declared.has(job)) problems.push(`${job}: validate.yml job is in neither JOB_RULES nor UNCONSUMED_JOBS`);
}
return problems;
}
const asBool = (value) => value === true || String(value) === 'true';
const runIdOf = (run) => Number(run?.id ?? run?.databaseId ?? 0);
const runAttemptOf = (run) => Number(run?.run_attempt ?? run?.runAttempt ?? run?.attempt ?? 1);
@@ -262,15 +325,15 @@ export function buildCiProof({
const sortedUnique = (values) => [...new Set(values)].sort();
const sameSet = (a, b) => JSON.stringify(sortedUnique(a)) === JSON.stringify(sortedUnique(b));
const jobsMatching = (jobs, rule) => (Array.isArray(jobs) ? jobs : []).filter((job) => (
rule.exact ? job?.name === rule.exact : String(job?.name || '').startsWith(rule.prefix)
));
function executedCheckIsGreen(id, jobs) {
return (JOB_RULES[id] || []).every((rule) => {
const matches = jobsMatching(jobs, rule);
return matches.length === rule.count && matches.every((job) => job.conclusion === 'success');
});
// Каждый экземпляр — ровно одна job с этим именем, и она зелёная. Лишняя job
// с похожим именем (другой размер матрицы в чужом YAML) доказательством не
// считается, недостающая — проваливает проверку.
function executedCheckIsGreen(rules, jobs) {
const list = Array.isArray(jobs) ? jobs : [];
return (rules || []).every((rule) => rule.names.every((name) => {
const matches = list.filter((job) => job?.name === name);
return matches.length === 1 && matches[0].conclusion === 'success';
}));
}
/**
@@ -279,6 +342,7 @@ function executedCheckIsGreen(id, jobs) {
*/
export function evaluateCiProof({
run, proof, jobs = [], reuseRuns = new Map(), candidate = {}, policy, expected = null, reviewedRun = undefined,
workflowJobs = undefined,
}) {
const result = (status, note) => ({ status, note, url: runUrlOf(run) });
if (!run) return result('missing', 'Validate run is missing');
@@ -336,11 +400,17 @@ export function evaluateCiProof({
}
if (run.conclusion !== 'success')
return result('failed', `Validate run ${runIdOf(run)} concluded ${run.conclusion || 'without success'}`);
let rules;
try {
rules = resolveJobRules(workflowJobs);
} catch (error) {
return result('failed', `job-name contract with validate.yml is broken (#622): ${error.message}`);
}
for (const id of derived) {
const claim = proof.checks?.[id];
if (!claim || claim.result !== 'success') return result('failed', `${id}: proof result is ${claim?.result || 'missing'}`);
if (claim.mode === 'executed') {
if (!executedCheckIsGreen(id, jobs)) return result('failed', `${id}: claimed execution is absent, incomplete or not green`);
if (!executedCheckIsGreen(rules[id], jobs)) return result('failed', `${id}: claimed execution is absent, incomplete or not green`);
continue;
}
if (claim.mode !== 'reused'
@@ -356,7 +426,7 @@ export function evaluateCiProof({
const source = reuseRuns instanceof Map ? reuseRuns.get(sourceKey) : reuseRuns?.[sourceKey];
if (!source || runIdOf(source.run) !== reuse.sourceRun || runAttemptOf(source.run) !== reuse.sourceAttempt
|| runShaOf(source.run) !== reuse.sourceSha
|| !executedCheckIsGreen(id, source.jobs)) {
|| !executedCheckIsGreen(rules[id], source.jobs)) {
return result('failed', `${id}: source run does not verify the reused successful job`);
}
}
+46 -11
View File
@@ -47,26 +47,55 @@ export function previousStable(releases, tag) {
}
/**
* Прогон — наш, если сьют, ставящий сам кандидат, назван по нему: имя job в
* e2e.yml — `"${suite} · HP ${ref} · HA ${ha}"`, и у `journeys`/`first-run`
* `ref` — это `houseplan_ref` (тег или SHA, #540). Сьют `upgrade` носит
* `upgrade_from` — тег ПРЕДЫДУЩЕГО stable, поэтому «любая job с HP <ref>»
* приняла бы прогон нового релиза за прогон старого (живой прогон 09.09:
* v1.72.0 ← run для v1.73.0).
* Контракт имени job с houseplan-e2e (#622). Строка — дословная копия `name:`
* матричной job в `Matysh/houseplan-e2e` `.github/workflows/e2e.yml` (сверено
* на 43899da5, 24.09.2026). Другого способа узнать свой прогон нет: dispatch
* API не возвращает id запуска, а `inputs` в ответе run не видны. Имя живёт в
* чужом репозитории, поэтому юнит-тест здесь ловит только правку этой
* стороны; переименование там — правка обеих сторон в один день, а разрыв
* виден громко: `missing` называет завершённые прогоны без job по контракту.
*/
export const E2E_JOB_NAME = '${{ matrix.suite }} · HP ${{ matrix.ref }} · HA ${{ matrix.ha }}';
const PLACEHOLDER = /\$\{\{\s*matrix\.([A-Za-z0-9_-]+)\s*\}\}/g;
/** Имя экземпляра job e2e.yml для значений матрицы; незаданная ось — ошибка. */
export function e2eJobName(values) {
return E2E_JOB_NAME.replace(PLACEHOLDER, (whole, axis) => {
if (values?.[axis] === undefined) throw new Error(`e2e job name: no value for matrix.${axis}`);
return String(values[axis]);
});
}
// Любой экземпляр по контракту: литералы шаблона как есть, оси — непустые.
const escapeRe = (text) => text.replace(/[.*+?^$()|[\]{}\\]/g, '\\$&');
const NAMED_JOB = new RegExp(`^${E2E_JOB_NAME.split(PLACEHOLDER)
.map((part, index) => (index % 2 ? '.+' : escapeRe(part))).join('')}$`);
const CUT = '\u0000';
/**
* Прогон — наш, если сьют, ставящий сам кандидат, назван по нему: у
* `journeys`/`first-run` ось `ref` — это `houseplan_ref` (тег или SHA, #540).
* Сьют `upgrade` носит `upgrade_from` — тег ПРЕДЫДУЩЕГО stable, поэтому
* «любая job с HP <ref>» приняла бы прогон нового релиза за прогон старого
* (живой прогон 09.09: v1.72.0 ← run для v1.73.0). Сравнивается всё имя до
* оси `ha`: версия HA в выборе не участвует.
*/
export const TAG_SUITES = ['journeys', 'first-run'];
export function isOurRun(jobs, ref) {
const needles = TAG_SUITES.map((suite) => `${suite} · HP ${ref} · `);
const needles = TAG_SUITES.map((suite) => e2eJobName({ suite, ref, ha: CUT }).split(CUT)[0]);
return (Array.isArray(jobs) ? jobs : []).some((job) => needles.some((needle) => String(job?.name || '').startsWith(needle)));
}
/** Job, названная по контракту E2E_JOB_NAME (а не служебная «Матрица прогона»). */
export const isNamedE2eJob = (job) => NAMED_JOB.test(String(job?.name || ''));
/**
* Прогон, у которого ещё нет ни одной job `· HP … ·`, решать рано: e2e.yml
* Прогон, у которого ещё нет ни одной job по контракту, решать рано: e2e.yml
* сначала планирует матрицу отдельной job, и первые секунды виден только
* «Матрица прогона». Живой прогон 09.09 записал такой run в чужие навсегда.
*/
export function classifyRun(jobs, ref) {
const named = (Array.isArray(jobs) ? jobs : []).filter((job) => / · HP .+ · /.test(String(job?.name || '')));
const named = (Array.isArray(jobs) ? jobs : []).filter(isNamedE2eJob);
if (!named.length) return 'unknown';
return isOurRun(named, ref) ? 'ours' : 'foreign';
}
@@ -91,6 +120,7 @@ export async function e2eGate({ tag, ref = tag, ops, appearMs = VALIDATE_APPEAR_
return { result: 'error', url: null, note: `запуск e2e.yml не удался: ${message}${forbidden ? ` — ${TOKEN_HINT}` : ''}` };
}
const foreign = new Set(); // dispatch-прогоны без нашего тега в именах job
const offContract = new Set(); // завершились, а ни одной job по E2E_JOB_NAME (#622)
let tracked = null;
while (ops.now() - started < totalMs) {
const runs = (await ops.listRuns()).filter((run) => !foreign.has(run.databaseId));
@@ -100,8 +130,10 @@ export async function e2eGate({ tag, ref = tag, ops, appearMs = VALIDATE_APPEAR_
for (const candidate of runs) {
const createdAt = Date.parse(candidate.createdAt || '') || 0;
if (createdAt < started - CLOCK_SKEW_MS) continue;
const kind = classifyRun(await ops.jobs(candidate.databaseId), ref);
const jobs = await ops.jobs(candidate.databaseId);
const kind = classifyRun(jobs, ref);
if (kind === 'ours') { run = candidate; break; }
if (kind === 'unknown' && candidate.status === 'completed' && jobs.length) offContract.add(candidate.databaseId);
if (kind === 'foreign' || candidate.status === 'completed') foreign.add(candidate.databaseId);
}
}
@@ -113,7 +145,10 @@ export async function e2eGate({ tag, ref = tag, ops, appearMs = VALIDATE_APPEAR_
return { result: 'red', url: run.url, note: `E2E на ${ref} завершился: ${run.conclusion}` };
}
} else if (ops.now() - started > appearMs) {
return { result: 'missing', url: null, note: `dispatch e2e.yml на ${ref} не появился за ${Math.round(appearMs / 60000)} мин` };
const contract = offContract.size
? `; ${offContract.size} завершённых прогона без job по контракту E2E_JOB_NAME — имя job в e2e.yml разошлось с scripts/e2e-gate.mjs (#622)`
: '';
return { result: 'missing', url: null, note: `dispatch e2e.yml на ${ref} не появился за ${Math.round(appearMs / 60000)} мин${contract}` };
}
await ops.sleep(pollMs);
}
+58 -2
View File
@@ -9881,8 +9881,64 @@ const MUTANT_DEFINITIONS = [
+ 'the independently fetched successful source job (#541)',
patches: [{
file: 'scripts/ci-proof.mjs',
find: " if (!source || runIdOf(source.run) !== reuse.sourceRun || runAttemptOf(source.run) !== reuse.sourceAttempt\n || runShaOf(source.run) !== reuse.sourceSha\n || !executedCheckIsGreen(id, source.jobs)) {",
replace: " if (false && (!source || runIdOf(source.run) !== reuse.sourceRun || runAttemptOf(source.run) !== reuse.sourceAttempt\n || runShaOf(source.run) !== reuse.sourceSha\n || !executedCheckIsGreen(id, source.jobs))) {",
find: " if (!source || runIdOf(source.run) !== reuse.sourceRun || runAttemptOf(source.run) !== reuse.sourceAttempt\n || runShaOf(source.run) !== reuse.sourceSha\n || !executedCheckIsGreen(rules[id], source.jobs)) {",
replace: " if (false && (!source || runIdOf(source.run) !== reuse.sourceRun || runAttemptOf(source.run) !== reuse.sourceAttempt\n || runShaOf(source.run) !== reuse.sourceSha\n || !executedCheckIsGreen(rules[id], source.jobs))) {",
}],
},
// #622: контракт имён job validate.yml с ci-proof / validate-gate / e2e-gate.
{
id: 'ci-proof-mutant-shards-are-a-constant',
guard: 'node --test --test-name-pattern="#622" test/ci-proof.test.mjs',
because: 'the number of mutant jobs a proof must show is the validate.yml matrix size; a fixed six '
+ 'silently accepts an incomplete run once the matrix grows (#622 AC2)',
patches: [{
file: 'scripts/ci-proof.mjs',
find: ' return { job: rule.job, names: jobInstanceNames(job) };',
replace: ' return { job: rule.job, names: jobInstanceNames(job).slice(0, 6) };',
}],
},
{
id: 'ci-proof-skips-job-name-contract',
guard: 'node --test --test-name-pattern="#622" test/ci-proof.test.mjs',
because: 'a job renamed in validate.yml must fail the proof with the job named, not pass on names '
+ 'the scripts no longer declare nor surface later as an absent execution (#622 AC1)',
patches: [{
file: 'scripts/ci-proof.mjs',
find: ' if (actual !== rule.name) {\n throw new Error(',
replace: ' if (false && actual !== rule.name) {\n throw new Error(',
}],
},
{
id: 'ci-proof-rule-name-drifts-from-workflow',
guard: 'node --test test/workflow-jobs.test.mjs',
because: 'a rule string edited in ci-proof without the same rename in validate.yml must turn the '
+ 'contract test red (#622 AC1)',
patches: [{
file: 'scripts/ci-proof.mjs',
find: " backend: [{ job: 'backend', name: 'Бэкенд: pytest в Home Assistant' }],",
replace: " backend: [{ job: 'backend', name: 'Бэкенд: pytest в HA' }],",
}],
},
{
id: 'job-contract-ignores-undeclared-jobs',
guard: 'node --test test/workflow-jobs.test.mjs',
because: 'a new validate.yml job is a decision — consumed by a rule or declared unconsumed — '
+ 'not a silent gap in the contract (#622 AC1)',
patches: [{
file: 'scripts/ci-proof.mjs',
find: ' if (!declared.has(job)) problems.push(',
replace: ' if (false && !declared.has(job)) problems.push(',
}],
},
{
id: 'e2e-gate-hides-broken-name-contract',
guard: 'node --test --test-name-pattern="#622" test/e2e-gate.test.mjs',
because: 'the e2e job name lives in houseplan-e2e; when it drifts, «missing» must name the broken '
+ 'contract instead of a plain «did not appear» (#622)',
patches: [{
file: 'scripts/e2e-gate.mjs',
find: " if (kind === 'unknown' && candidate.status === 'completed' && jobs.length) offContract.add(candidate.databaseId);",
replace: " if (false && kind === 'unknown' && candidate.status === 'completed' && jobs.length) offContract.add(candidate.databaseId);",
}],
},
{
+4 -2
View File
@@ -24,7 +24,7 @@ import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import { VALIDATE_APPEAR_MS, VALIDATE_TOTAL_MS } from './merge-candidate.mjs';
import {
CI_PROOF_POLICIES, evaluateCiProof, githubCandidateTree, loadGithubProofContext,
CI_PROOF_POLICIES, MUTANT_JOB_PREFIX, evaluateCiProof, githubCandidateTree, loadGithubProofContext,
} from './ci-proof.mjs';
export const POLL_MS = 20_000;
@@ -45,7 +45,9 @@ export function isMutantRun(run) {
return run?.event === 'workflow_dispatch';
}
export const MUTANT_JOB_PREFIX = 'Мутанты по диффу';
// #622: префикс — из контракта ci-proof (JOB_RULES.mutants), который сверяется
// с validate.yml; своей строки здесь больше нет.
export { MUTANT_JOB_PREFIX };
/**
* Зелёный dispatch доказывает мутанты, только если их job реально исполнены
+154
View File
@@ -0,0 +1,154 @@
// #622: имена и размеры матриц job Validate — из самого `validate.yml`.
//
// Ревью, слияние и релиз (ci-proof.mjs) опознают исполненную проверку по
// имени job в ответе API: это единственное, что GitHub отдаёт о job, кроме
// исхода. Имена жили строками в скриптах и тестах, а число шардов — константой
// `count: 6`; переименование job или смена матрицы давали
// `claimed execution is absent` у всех трёх потребителей без единого красного
// теста. Теперь число экземпляров матричной job читается отсюда, а имена
// сверяет контрактный тест (test/workflow-jobs.test.mjs) в обе стороны.
//
// Полноценного YAML-парсера в зависимостях нет, и заводить его ради одного
// файла незачем: разбирается ровно та структура, которую пишет validate.yml —
// `jobs:` на нулевом отступе, id job на двух пробелах, `name:` на четырёх,
// `strategy.matrix` со списками в строку (`shard: [1, 2, 3]`). Всё прочее
// (`include`, `fromJSON`, блочные списки) — громкая ошибка, а не догадка:
// разбор, который молча вернёт не то, повторил бы ровно ту беду, что чинится.
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
export const VALIDATE_WORKFLOW_PATH = resolve(HERE, '..', '.github', 'workflows', 'validate.yml');
const indentOf = (line) => line.length - line.trimStart().length;
const isBlank = (line) => !line.trim() || line.trimStart().startsWith('#');
function unquote(raw, where) {
const text = raw.trim();
const quote = text[0];
if (quote === '"' || quote === "'") {
// Закрывающая кавычка ищется до комментария: `#` внутри кавычек — часть имени.
let value = '';
let i = 1;
for (; i < text.length; i += 1) {
const ch = text[i];
if (quote === '"' && ch === '\\') { value += text[i + 1] ?? ''; i += 1; continue; }
if (ch === quote) {
if (quote === "'" && text[i + 1] === "'") { value += "'"; i += 1; continue; }
break;
}
value += ch;
}
const rest = text.slice(i + 1).trim();
if (i >= text.length || (rest && !rest.startsWith('#'))) throw new Error(`${where}: malformed quoted name ${JSON.stringify(text)}`);
return value;
}
if (/^[|>]/.test(text)) throw new Error(`${where}: block scalar names are not supported`);
return text.replace(/\s+#.*$/, '').trim();
}
function flowList(raw, where) {
const value = raw.replace(/\s+#.*$/, '').trim();
const match = value.match(/^\[(.*)\]$/);
if (!match) throw new Error(`${where}: only inline lists like [1, 2, 3] are supported, got ${JSON.stringify(value)}`);
const items = match[1].split(',').map((item) => item.trim()).filter(Boolean);
if (!items.length) throw new Error(`${where}: empty matrix axis`);
return items;
}
/**
* Job-уровень workflow: id → { name, matrix: { axis: [values] } | null, size }.
* `size` — сколько job GitHub развернёт из матрицы (произведение осей), 1 без неё.
*/
export function parseWorkflowJobs(text, file = 'workflow') {
const lines = String(text).split(/\r?\n/);
const start = lines.findIndex((line) => /^jobs:\s*(#.*)?$/.test(line));
if (start < 0) throw new Error(`${file}: no top-level "jobs:" key`);
const jobs = new Map();
let job = null;
let section = null; // 'strategy' | 'matrix' | null
for (let i = start + 1; i < lines.length; i += 1) {
const line = lines[i];
if (isBlank(line)) continue;
const indent = indentOf(line);
const where = `${file}:${i + 1}`;
if (indent === 0) break; // следующий ключ верхнего уровня
const key = line.trim().match(/^([A-Za-z0-9_-]+):(.*)$/);
if (indent === 2) {
if (!key || key[2].trim()) throw new Error(`${where}: expected a job id, got ${JSON.stringify(line.trim())}`);
if (jobs.has(key[1])) throw new Error(`${where}: duplicate job id ${key[1]}`);
job = { id: key[1], name: null, matrix: null };
jobs.set(job.id, job);
section = null;
continue;
}
if (!job) throw new Error(`${where}: content before the first job id`);
if (indent === 4) {
section = null;
if (!key) continue;
if (key[1] === 'name') {
if (job.name !== null) throw new Error(`${where}: job ${job.id} has two names`);
job.name = unquote(key[2], where);
if (!job.name) throw new Error(`${where}: job ${job.id} has an empty name`);
} else if (key[1] === 'strategy') {
section = 'strategy';
}
continue;
}
if (section === 'strategy' && indent === 6) {
if (key?.[1] === 'matrix') {
if (key[2].trim()) throw new Error(`${where}: job ${job.id}: matrix expressions are not supported, list the axes explicitly`);
section = 'matrix';
job.matrix = {};
}
continue;
}
if (section === 'matrix') {
if (indent === 6) { section = 'strategy'; if (key?.[1] === 'matrix') throw new Error(`${where}: duplicate matrix`); continue; }
if (indent === 8) {
if (!key) throw new Error(`${where}: unsupported matrix entry ${JSON.stringify(line.trim())}`);
if (key[1] === 'include' || key[1] === 'exclude') throw new Error(`${where}: job ${job.id}: matrix ${key[1]} is not supported`);
job.matrix[key[1]] = flowList(key[2], where);
continue;
}
throw new Error(`${where}: unsupported matrix layout in job ${job.id}`);
}
}
if (!jobs.size) throw new Error(`${file}: "jobs:" has no jobs`);
for (const entry of jobs.values()) {
if (!entry.name) throw new Error(`${file}: job ${entry.id} has no name — API would report the id, and consumers match by name`);
if (entry.matrix && !Object.keys(entry.matrix).length) throw new Error(`${file}: job ${entry.id} has an empty matrix`);
entry.size = entry.matrix ? Object.values(entry.matrix).reduce((n, axis) => n * axis.length, 1) : 1;
}
return jobs;
}
/** Неизменная часть имени: всё до первого `${{`. У матричной job это общий префикс её экземпляров. */
export const staticNamePrefix = (name) => String(name).split('${{')[0];
/**
* Имена, под которыми GitHub покажет экземпляры job: шаблон `${{ matrix.<ось> }}`
* раскрывается по всем сочетаниям осей в порядке их объявления. Другие
* выражения в имени не раскрываются — громкая ошибка: угадать их значение
* по файлу нельзя, а потребитель сверяет имя точно.
*/
export function jobInstanceNames(job) {
const axes = Object.entries(job.matrix || {});
let combos = [{}];
for (const [axis, values] of axes) combos = combos.flatMap((combo) => values.map((value) => ({ ...combo, [axis]: value })));
return combos.map((combo) => job.name.replace(/\$\{\{\s*([^}]*?)\s*\}\}/g, (whole, expr) => {
const axis = expr.match(/^matrix\.([A-Za-z0-9_-]+)$/)?.[1];
if (!axis || !(axis in combo)) throw new Error(`job ${job.id}: name expression ${whole} is not a declared matrix axis`);
return combo[axis];
}));
}
let cached = null;
/** Job-уровень `validate.yml` этого checkout; читается один раз на процесс. */
export function validateJobs(path = VALIDATE_WORKFLOW_PATH) {
if (path !== VALIDATE_WORKFLOW_PATH) return parseWorkflowJobs(readFileSync(path, 'utf8'), path);
if (!cached) cached = parseWorkflowJobs(readFileSync(path, 'utf8'), '.github/workflows/validate.yml');
return cached;
}