mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
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:
+97
-27
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);",
|
||||
}],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 реально исполнены
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user