From 15148997467e3c698ab7dd70fa4e2d04314f65ec Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 04:07:53 +0300 Subject: [PATCH] =?UTF-8?q?ci:=20=D0=B8=D0=BC=D0=B5=D0=BD=D0=B0=20job=20va?= =?UTF-8?q?lidate.yml=20=E2=80=94=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BA=D1=82,=20=D1=81=D0=B2=D0=B5=D1=80=D1=8F=D0=B5=D0=BC?= =?UTF-8?q?=D1=8B=D0=B9=20=D1=81=20=D1=84=D0=B0=D0=B9=D0=BB=D0=BE=D0=BC;?= =?UTF-8?q?=20=D1=88=D0=B0=D1=80=D0=B4=D1=8B=20=D0=B8=D0=B7=20=D0=BC=D0=B0?= =?UTF-8?q?=D1=82=D1=80=D0=B8=D1=86=D1=8B=20(#622)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- scripts/ci-proof.mjs | 124 +++++++++++++++++++------ scripts/e2e-gate.mjs | 57 +++++++++--- scripts/mutation-registry.mjs | 60 ++++++++++++- scripts/validate-gate.mjs | 6 +- scripts/workflow-jobs.mjs | 154 ++++++++++++++++++++++++++++++++ test/ci-proof.test.mjs | 89 +++++++++++++----- test/e2e-gate.test.mjs | 35 +++++++- test/merge-candidate.test.mjs | 13 ++- test/release-gate.test.mjs | 18 ++-- test/validate-gate.test.mjs | 14 +-- test/validate-workflow.test.mjs | 10 ++- test/workflow-jobs.test.mjs | 106 ++++++++++++++++++++++ 12 files changed, 593 insertions(+), 93 deletions(-) create mode 100644 scripts/workflow-jobs.mjs create mode 100644 test/workflow-jobs.test.mjs diff --git a/scripts/ci-proof.mjs b/scripts/ci-proof.mjs index dd144724..7eaaf494 100644 --- a/scripts/ci-proof.mjs +++ b/scripts/ci-proof.mjs @@ -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`); } } diff --git a/scripts/e2e-gate.mjs b/scripts/e2e-gate.mjs index 7f0417f6..9550ff8f 100755 --- a/scripts/e2e-gate.mjs +++ b/scripts/e2e-gate.mjs @@ -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 » - * приняла бы прогон нового релиза за прогон старого (живой прогон 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 » приняла бы прогон нового релиза за прогон старого + * (живой прогон 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); } diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index a990e4b3..bdf67e49 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -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);", }], }, { diff --git a/scripts/validate-gate.mjs b/scripts/validate-gate.mjs index dcf8c9fd..1fb80b0f 100755 --- a/scripts/validate-gate.mjs +++ b/scripts/validate-gate.mjs @@ -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 реально исполнены diff --git a/scripts/workflow-jobs.mjs b/scripts/workflow-jobs.mjs new file mode 100644 index 00000000..225f4ce6 --- /dev/null +++ b/scripts/workflow-jobs.mjs @@ -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; +} diff --git a/test/ci-proof.test.mjs b/test/ci-proof.test.mjs index 4a34060c..6ff21b88 100644 --- a/test/ci-proof.test.mjs +++ b/test/ci-proof.test.mjs @@ -5,11 +5,13 @@ import { deflateRawSync } from 'node:zlib'; import { fileURLToPath } from 'node:url'; import { - CI_PROOF_POLICIES, baselineReviewedRun, buildCiProof, evaluateCiProof, loadGithubProofContext, localEvidence, + CI_PROOF_POLICIES, MUTANT_JOB_PREFIX, baselineReviewedRun, buildCiProof, evaluateCiProof, loadGithubProofContext, localEvidence, parseReuseMarker, productTreeId, readCiProofArtifact, requiredCheckIds, selectCiProofVerdict, } from '../scripts/ci-proof.mjs'; import { REUSE_JOBS } from '../scripts/check-inputs.mjs'; import { reuseKey } from '../scripts/gate-reuse.mjs'; +import { jobInstanceNames, parseWorkflowJobs, validateJobs, VALIDATE_WORKFLOW_PATH } from '../scripts/workflow-jobs.mjs'; +import { readFileSync } from 'node:fs'; export const SHA = 'a'.repeat(40); export const TREE = 'b'.repeat(40); @@ -33,27 +35,27 @@ function zipWith(proof) { return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); } +// #622: имена job — из validate.yml, а не копией строк: тест, который держит +// свои имена, зеленеет и тогда, когда потребитель с файлом разошёлся. +const WORKFLOW = validateJobs(); +const namesOf = (id) => jobInstanceNames(WORKFLOW.get(id)); const names = { - preflight: 'Предполёт: документация, провенанс, процесс', - changes: 'Классификация изменённых файлов', - reuse: 'Переиспользование: это дерево уже проверено', - frontend: 'Фронтенд: типы, юниты, мутанты, синхрон бандла', - hacs: 'HACS: валидация репозитория', - hassfest: 'Hassfest: манифест интеграции', - smokeDone: 'Смоки: все шарды зелёные', - golden: 'Golden-кадры против принятых эталонов', - performance: 'Перф-смок: бюджет времени кадра', - geometryParity: 'Геометрия: TS/Python parity исполнена', - backend: 'Бэкенд: pytest в Home Assistant', + preflight: namesOf('preflight')[0], + changes: namesOf('changes')[0], + reuse: namesOf('reuse')[0], + frontend: namesOf('frontend')[0], + hacs: namesOf('hacs')[0], + hassfest: namesOf('hassfest')[0], + smokeDone: namesOf('smoke_done')[0], + golden: namesOf('golden')[0], + performance: namesOf('performance_smoke')[0], + geometryParity: namesOf('geometry_parity')[0], + backend: namesOf('backend')[0], }; const success = (name) => ({ name, conclusion: 'success' }); -const mutantJobs = () => Array.from({ length: 6 }, (_, index) => ( - success(`Мутанты по диффу (${index + 1}/6): затронутые свидетели краснеют`) -)); -const smokeJobs = () => Array.from({ length: 3 }, (_, index) => ( - success(`Смоки в браузере (шард ${index + 1} из 3)`) -)); +const mutantJobs = () => namesOf('changed_mutants').map(success); +const smokeJobs = () => namesOf('smoke').map(success); export function proofFixture({ id = 10, attempt = 2, full = true, mutants = true, @@ -158,9 +160,9 @@ test('#601 AC3: release policy accepts a full proof without requested mutants; l } }); -test('#541 AC: green dispatch without six executed mutant jobs proves neither review nor merge', () => { +test('#541 AC: green dispatch without the executed mutant jobs proves neither review nor merge', () => { const fixture = proofFixture({ full: false, backend: false, integration: false }); - fixture.jobs = fixture.jobs.filter((job) => !job.name.startsWith('Мутанты по диффу')); + fixture.jobs = fixture.jobs.filter((job) => !job.name.startsWith(MUTANT_JOB_PREFIX)); for (const policy of [CI_PROOF_POLICIES.review, CI_PROOF_POLICIES.merge]) { const verdict = evaluateCiProof({ ...fixture, policy }); assert.equal(verdict.status, 'failed', policy.name); @@ -168,6 +170,51 @@ test('#541 AC: green dispatch without six executed mutant jobs proves neither re } }); +// #622 AC2: сколько mutant-jobs обязано исполниться — говорит матрица +// validate.yml. Тот же набор job против файла с семью шардами — не +// доказательство; семь job по файлу с семью — доказательство. +const WORKFLOW_TEXT = readFileSync(VALIDATE_WORKFLOW_PATH, 'utf8').replace(/\r\n/g, '\n'); +const withMutantShards = (count) => { + const shards = Array.from({ length: count }, (_, i) => i + 1).join(', '); + const text = WORKFLOW_TEXT.replace(/(\n changed_mutants:\n[\s\S]*?\n shard: )\[[^\]]*\]/, `$1[${shards}]`); + assert.notEqual(text, WORKFLOW_TEXT, 'fixture must rewrite the changed_mutants matrix'); + return parseWorkflowJobs(text, 'validate.yml (fixture)'); +}; + +test('#622 AC2: the number of mutant shards comes from the validate.yml matrix, not a constant', () => { + assert.equal(namesOf('changed_mutants').length, WORKFLOW.get('changed_mutants').size); + const policy = CI_PROOF_POLICIES.review; + const fixture = proofFixture({ full: false, backend: false, integration: false }); + assert.equal(evaluateCiProof({ ...fixture, policy }).status, 'green'); + const oneShardShort = { ...fixture, jobs: fixture.jobs.filter((job) => job.name !== namesOf('changed_mutants').at(-1)) }; + assert.match(evaluateCiProof({ ...oneShardShort, policy }).note, /mutants: claimed execution/); + const seven = withMutantShards(7); + const verdict = evaluateCiProof({ ...fixture, policy, workflowJobs: seven }); + assert.equal(verdict.status, 'failed', 'six jobs do not prove a seven-shard matrix'); + assert.match(verdict.note, /mutants: claimed execution/); + const sevenJobs = [ + ...fixture.jobs.filter((job) => !job.name.startsWith(MUTANT_JOB_PREFIX)), + ...jobInstanceNames(seven.get('changed_mutants')).map(success), + ]; + assert.equal(sevenJobs.filter((job) => job.name.startsWith(MUTANT_JOB_PREFIX)).length, 7); + assert.equal(evaluateCiProof({ ...fixture, jobs: sevenJobs, policy, workflowJobs: seven }).status, 'green'); + const duplicated = { ...fixture, jobs: [...fixture.jobs, success(namesOf('changed_mutants')[0])] }; + assert.equal(evaluateCiProof({ ...duplicated, policy }).status, 'failed', 'an instance reported twice is ambiguous, not proof'); +}); + +test('#622 AC1: a job renamed in validate.yml fails the proof with the named job, not as an absent execution', () => { + const fixture = proofFixture(); + const renamed = WORKFLOW_TEXT.replace('name: "Бэкенд: pytest в Home Assistant"', 'name: "Бэкенд: pytest в HA"'); + assert.notEqual(renamed, WORKFLOW_TEXT); + const workflowJobs = parseWorkflowJobs(renamed, 'validate.yml (fixture)'); + // Имена job в прогоне — по тому же переименованному файлу: без сверки + // контракта такой прогон прошёл бы, а rules в скриптах молча устарели. + const jobs = fixture.jobs.map((job) => (job.name === names.backend ? { ...job, name: 'Бэкенд: pytest в HA' } : job)); + const verdict = evaluateCiProof({ ...fixture, jobs, workflowJobs, policy: CI_PROOF_POLICIES.release }); + assert.equal(verdict.status, 'failed'); + assert.match(verdict.note, /job-name contract with validate\.yml is broken \(#622\): backend: validate\.yml job backend is named/); +}); + test('#541 AC: SHA, tree, run attempt, event and proof inventories cannot drift', () => { const fixture = proofFixture(); assert.equal(evaluateCiProof({ ...fixture, run: { ...fixture.run, attempt: 3 }, policy: CI_PROOF_POLICIES.release }).status, 'stale'); @@ -319,7 +366,7 @@ test('#573 AC1: baseline-only коммит — reused smoke/perf из красн test('#573 AC3: красный smoke кандидата не прячется за зелёной golden — источник reuse обязан быть зелёной job', () => { const fixture = baselineOnlyFixture(); fixture.reuseRuns.get('10:1').jobs = fixture.reuseRuns.get('10:1').jobs - .map((job) => (job.name.startsWith('Смоки в браузере (шард 2') ? { ...job, conclusion: 'failure' } : job)); + .map((job) => (job.name === namesOf('smoke')[1] ? { ...job, conclusion: 'failure' } : job)); const verdict = evaluateCiProof({ ...fixture, policy: CI_PROOF_POLICIES.release }); assert.equal(verdict.status, 'failed'); assert.match(verdict.note, /smoke: source run does not verify/); diff --git a/test/e2e-gate.test.mjs b/test/e2e-gate.test.mjs index 270a9f5d..24245017 100755 --- a/test/e2e-gate.test.mjs +++ b/test/e2e-gate.test.mjs @@ -2,7 +2,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { classifyRun, e2eGate, isOurRun, previousStable, realOps, TOKEN_HINT } from '../scripts/e2e-gate.mjs'; +import { + classifyRun, E2E_JOB_NAME, e2eGate, e2eJobName, isNamedE2eJob, isOurRun, previousStable, realOps, TAG_SUITES, TOKEN_HINT, +} from '../scripts/e2e-gate.mjs'; const TAG = 'v1.74.0'; const ours = (suffix = '') => [{ name: `journeys · HP ${TAG} · HA stable${suffix}`, conclusion: 'success' }, { name: 'upgrade · HP stable · HA stable', conclusion: 'success' }]; @@ -182,3 +184,34 @@ test('#540: without --ref the gate behaves exactly as before — the tag is the assert.equal(outcome.result, 'green'); assert.deepEqual(fake.dispatched, [[TAG, 'v1.73.0']]); }); + +// #622: имя job живёт в чужом репозитории (Matysh/houseplan-e2e), поэтому +// тест держит зеркало его строки `name:` и доказывает две вещи: гейт узнаёт +// ровно те имена, которые этот шаблон порождает, и разрыв контракта виден +// в исходе громко, а не как молчаливое «не появился». +// Зеркало: houseplan-e2e .github/workflows/e2e.yml, job `e2e`, коммит 43899da5. +const E2E_YML_NAME_LINE = ' name: "${{ matrix.suite }} · HP ${{ matrix.ref }} · HA ${{ matrix.ha }}"'; + +test('#622: the job-name contract mirrors houseplan-e2e e2e.yml and drives recognition', () => { + assert.equal(` name: "${E2E_JOB_NAME}"`, E2E_YML_NAME_LINE, 'e2e-gate and the pinned e2e.yml line disagree — change both sides together'); + for (const suite of [...TAG_SUITES, 'upgrade']) { + assert.equal(isNamedE2eJob({ name: e2eJobName({ suite, ref: TAG, ha: 'stable' }) }), true, suite); + } + assert.equal(isNamedE2eJob({ name: 'Матрица прогона' }), false); + for (const suite of TAG_SUITES) { + assert.equal(classifyRun([{ name: e2eJobName({ suite, ref: TAG, ha: '2026.9.1' }) }], TAG), 'ours', suite); + } + assert.equal(classifyRun([{ name: e2eJobName({ suite: 'upgrade', ref: TAG, ha: 'stable' }) }], TAG), 'foreign', + 'upgrade carries upgrade_from, not the candidate'); + assert.throws(() => e2eJobName({ suite: 'journeys', ref: TAG }), /no value for matrix\.ha/); +}); + +test('#622: completed runs whose jobs do not follow the contract make «missing» name the broken contract', async () => { + const renamed = run({ databaseId: 4, url: 'https://e2e/run/4' }); + const fake = fakeOps({ snapshots: [[renamed]], jobsById: { 4: [{ name: 'Матрица прогона' }, { name: `journeys / ${TAG} / stable`, conclusion: 'success' }] } }); + const outcome = await e2eGate({ tag: TAG, ops: fake.ops, appearMs: 5000, pollMs: 1000 }); + assert.equal(outcome.result, 'missing'); + assert.match(outcome.note, /1 завершённых прогона без job по контракту E2E_JOB_NAME/); + const nothing = await e2eGate({ tag: TAG, ops: fakeOps({ snapshots: [[]] }).ops, appearMs: 5000, pollMs: 1000 }); + assert.doesNotMatch(nothing.note, /E2E_JOB_NAME/, 'no runs at all is plain missing'); +}); diff --git a/test/merge-candidate.test.mjs b/test/merge-candidate.test.mjs index 4fa0e2c8..db7b2640 100755 --- a/test/merge-candidate.test.mjs +++ b/test/merge-candidate.test.mjs @@ -9,6 +9,10 @@ import { MAX_ATTEMPTS, MAX_COMMAND_OUTPUT_BYTES, commentFor, decideMerge, mergeCandidate, realOps, sh, } from '../scripts/merge-candidate.mjs'; import { buildCiProof } from '../scripts/ci-proof.mjs'; +import { jobInstanceNames, validateJobs } from '../scripts/workflow-jobs.mjs'; + +// #622: имена и число экземпляров job — из validate.yml, не копией строк. +const WORKFLOW = validateJobs(); const mergeProofContext = (row, sha, tree) => { const proof = buildCiProof({ @@ -25,13 +29,8 @@ const mergeProofContext = (row, sha, tree) => { }, }); const success = (name) => ({ name, conclusion: 'success' }); - return { proof, reuseRuns: new Map(), jobs: [ - success('Предполёт: документация, провенанс, процесс'), - success('Классификация изменённых файлов'), - success('Переиспользование: это дерево уже проверено'), - success('Фронтенд: типы, юниты, мутанты, синхрон бандла'), - ...Array.from({ length: 6 }, (_, i) => success(`Мутанты по диффу (${i + 1}/6): затронутые свидетели краснеют`)), - ] }; + return { proof, reuseRuns: new Map(), jobs: ['preflight', 'changes', 'reuse', 'frontend', 'changed_mutants'] + .flatMap((id) => jobInstanceNames(WORKFLOW.get(id)).map(success)) }; }; // #492 §4 / §8.4: слияние точного кандидата. Таблица решений — на чистой diff --git a/test/release-gate.test.mjs b/test/release-gate.test.mjs index 57f4caff..7181f28e 100644 --- a/test/release-gate.test.mjs +++ b/test/release-gate.test.mjs @@ -7,10 +7,14 @@ import { candidateExpectations, classifyValidateProofs, classifyValidateRuns, latestRelevantRun, workflowRunsUrl, } from '../scripts/release-gate.mjs'; import { buildCiProof, localEvidence } from '../scripts/ci-proof.mjs'; +import { jobInstanceNames, validateJobs } from '../scripts/workflow-jobs.mjs'; const SHA = 'a'.repeat(40); const TREE = 'b'.repeat(40); const greenJob = (name) => ({ name, conclusion: 'success' }); +// #622: имена и число экземпляров — из validate.yml, не копией строк. +const WORKFLOW = validateJobs(); +const greenJobs = (...ids) => ids.flatMap((id) => jobInstanceNames(WORKFLOW.get(id)).map(greenJob)); const proofContext = ({ id, full = true, conclusion = 'success' }) => { const needs = { preflight: { result: 'success' }, changes: { result: 'success', outputs: { @@ -24,18 +28,8 @@ const proofContext = ({ id, full = true, conclusion = 'success' }) => { performance_smoke: { result: full ? 'success' : 'skipped' }, backend: { result: full ? 'success' : 'skipped' }, }; const proof = buildCiProof({ candidateSha: SHA, candidateTree: TREE, runId: id, attempt: 1, event: 'workflow_dispatch', needs }); - const jobs = [ - greenJob('Предполёт: документация, провенанс, процесс'), - greenJob('Классификация изменённых файлов'), greenJob('Переиспользование: это дерево уже проверено'), - greenJob('Фронтенд: типы, юниты, мутанты, синхрон бандла'), - ...Array.from({ length: 6 }, (_, i) => greenJob(`Мутанты по диффу (${i + 1}/6): затронутые свидетели краснеют`)), - ]; - if (full) jobs.push( - greenJob('HACS: валидация репозитория'), greenJob('Hassfest: манифест интеграции'), - ...Array.from({ length: 3 }, (_, i) => greenJob(`Смоки в браузере (шард ${i + 1} из 3)`)), - greenJob('Смоки: все шарды зелёные'), greenJob('Golden-кадры против принятых эталонов'), - greenJob('Перф-смок: бюджет времени кадра'), greenJob('Бэкенд: pytest в Home Assistant'), - ); + const jobs = greenJobs('preflight', 'changes', 'reuse', 'frontend', 'changed_mutants'); + if (full) jobs.push(...greenJobs('hacs', 'hassfest', 'smoke', 'smoke_done', 'golden', 'performance_smoke', 'backend')); const run = { databaseId: id, attempt: 1, status: 'completed', conclusion, event: 'workflow_dispatch', headSha: SHA, url: `https://run/${id}`, startedAt: `2026-09-13T10:${id}:00Z`, diff --git a/test/validate-gate.test.mjs b/test/validate-gate.test.mjs index 0dbf7e3a..05b6a459 100755 --- a/test/validate-gate.test.mjs +++ b/test/validate-gate.test.mjs @@ -4,18 +4,18 @@ import test from 'node:test'; import { validateGate, isMutantRun, provesMutants } from '../scripts/validate-gate.mjs'; import { buildCiProof } from '../scripts/ci-proof.mjs'; +import { jobInstanceNames, validateJobs } from '../scripts/workflow-jobs.mjs'; const SHA = 'a'.repeat(40); const TREE = 'b'.repeat(40); /** Fake gh: a scripted list of run snapshots per call, a virtual clock. */ -const MUTANT_JOBS = [1, 2, 3, 4, 5, 6].map((n) => ({ name: `Мутанты по диффу (${n}/6): затронутые свидетели краснеют`, conclusion: 'success' })); -const OTHER_JOBS = [{ name: 'Фронтенд: типы, юниты, мутанты, синхрон бандла', conclusion: 'success' }]; -const BASE_JOBS = [ - { name: 'Предполёт: документация, провенанс, процесс', conclusion: 'success' }, - { name: 'Классификация изменённых файлов', conclusion: 'success' }, - { name: 'Переиспользование: это дерево уже проверено', conclusion: 'success' }, -]; +// #622: имена и число экземпляров job — из validate.yml, не копией строк. +const WORKFLOW = validateJobs(); +const jobsOf = (...ids) => ids.flatMap((id) => jobInstanceNames(WORKFLOW.get(id)).map((name) => ({ name, conclusion: 'success' }))); +const MUTANT_JOBS = jobsOf('changed_mutants'); +const OTHER_JOBS = jobsOf('frontend'); +const BASE_JOBS = jobsOf('preflight', 'changes', 'reuse'); function fakeOps({ snapshots, onRef = [], jobsById = {} }) { let clock = 0; diff --git a/test/validate-workflow.test.mjs b/test/validate-workflow.test.mjs index 0a41d773..d07f6d19 100644 --- a/test/validate-workflow.test.mjs +++ b/test/validate-workflow.test.mjs @@ -4,6 +4,8 @@ import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'n import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { JOB_RULES } from '../scripts/ci-proof.mjs'; +import { validateJobs } from '../scripts/workflow-jobs.mjs'; // #336. Воркфлоу — не текст, а контракт, и ломается он молча: висячая // зависимость `needs` не роняет YAML, а просто навсегда пропускает job, и @@ -493,8 +495,10 @@ test('журнал свидетелей changed_mutants: rerun продолжа assert.match(restore, /key: mutation-ledger-\$\{\{ matrix\.shard \}\}-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/); assert.match(restore, /mutation-ledger-\$\{\{ matrix\.shard \}\}-\$\{\{ github\.run_id \}\}-/, 'rerun обязан восстановить предыдущую попытку того же run'); assert.match(restore, /^\s+mutation-ledger-\$\{\{ matrix\.shard \}\}-\s*$/m, 'новый run обязан найти последний журнал шарда'); - assert.match(job, /--changed="\$base\.\.\$HEAD_SHA" --shard="\$SHARD\/6" \\\n\s+--ledger="artifacts\/mutation-ledger\/shard-\$SHARD\.json" --plan-only/); - assert.match(job, /--changed="\$BASE\.\.\$HEAD_SHA" --shard="\$SHARD\/6" \\\n\s+--ledger="artifacts\/mutation-ledger\/shard-\$SHARD\.json"/); + // #622: число шардов — из матрицы, а не отдельной шестёркой в тесте. + const shards = validateJobs().get('changed_mutants').size; + assert.match(job, new RegExp(`--changed="\\$base\\.\\.\\$HEAD_SHA" --shard="\\$SHARD/${shards}" \\\\\\n\\s+--ledger="artifacts/mutation-ledger/shard-\\$SHARD\\.json" --plan-only`)); + assert.match(job, new RegExp(`--changed="\\$BASE\\.\\.\\$HEAD_SHA" --shard="\\$SHARD/${shards}" \\\\\\n\\s+--ledger="artifacts/mutation-ledger/shard-\\$SHARD\\.json"`)); // #518: журнал обязан восстанавливаться ДО плана, иначе план не увидит // уже пойманных и шард заплатит за окружение впустую. assert.ok(job.indexOf('actions/cache/restore@') < job.indexOf('name: План шарда'), 'restore журнала идёт до плана'); @@ -508,7 +512,7 @@ test('журнал свидетелей changed_mutants: rerun продолжа test('#541: Validate всегда публикует proof точной попытки, а reuse раскрывает источник', () => { const workflow = read('validate.yml'); const preflightName = workflow.match(/\n preflight:\n name: "([^"]+)"/)?.[1]; - assert.equal(preflightName, 'Предполёт: документация, провенанс, процесс'); + assert.equal(preflightName, JOB_RULES.preflight[0].name, '#622: имя — контракт ci-proof, не своя копия'); assert.ok(Buffer.byteLength(preflightName, 'utf8') <= 100, 'GitHub Jobs API truncates job names longer than 100 UTF-8 bytes'); assert.doesNotMatch(workflow, /with:\s*\{[^\n]*\$\{\{/, diff --git a/test/workflow-jobs.test.mjs b/test/workflow-jobs.test.mjs new file mode 100644 index 00000000..d34b9be6 --- /dev/null +++ b/test/workflow-jobs.test.mjs @@ -0,0 +1,106 @@ +// #622: контракт имён job validate.yml с потребителями (ci-proof, validate-gate). +// Имена job — единственное, по чему ревью, слияние и релиз узнают исполненную +// проверку; расхождение с файлом давало «claimed execution is absent» без +// единого красного теста. Здесь файл читается как есть и сверяется в обе +// стороны. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; + +import { + jobInstanceNames, parseWorkflowJobs, staticNamePrefix, validateJobs, VALIDATE_WORKFLOW_PATH, +} from '../scripts/workflow-jobs.mjs'; +import { JOB_RULES, MUTANT_JOB_PREFIX, UNCONSUMED_JOBS, jobContractProblems, resolveJobRules } from '../scripts/ci-proof.mjs'; +import { MUTANT_JOB_PREFIX as GATE_MUTANT_PREFIX } from '../scripts/validate-gate.mjs'; + +const TEXT = readFileSync(VALIDATE_WORKFLOW_PATH, 'utf8').replace(/\r\n/g, '\n'); +const JOBS = validateJobs(); + +test('#622: parser reads job ids, names and inline matrices of the structure validate.yml uses', () => { + const jobs = parseWorkflowJobs([ + 'name: top', 'on: push', 'jobs:', + ' # comment', ' plain:', ' name: Просто имя # хвост', ' runs-on: ubuntu-latest', + ' steps:', ' - name: не имя job', ' run: |', ' name: и не это', + ' quoted:', ' needs: plain', ' name: "Шард ${{ matrix.shard }}/${{ matrix.os }} #1"', + ' strategy:', ' fail-fast: false', ' matrix:', ' shard: [1, 2, 3]', " os: ['a', b]", + ' runs-on: x', + 'env:', ' name: not-a-job', + ].join('\n')); + assert.deepEqual([...jobs.keys()], ['plain', 'quoted']); + assert.equal(jobs.get('plain').name, 'Просто имя'); + assert.equal(jobs.get('plain').size, 1); + assert.equal(jobs.get('quoted').name, 'Шард ${{ matrix.shard }}/${{ matrix.os }} #1'); + assert.equal(jobs.get('quoted').size, 6); + assert.equal(staticNamePrefix(jobs.get('quoted').name), 'Шард '); + assert.deepEqual(jobInstanceNames(jobs.get('quoted')).slice(0, 3), ["Шард 1/'a' #1", 'Шард 1/b #1', "Шард 2/'a' #1"]); +}); + +test('#622: forms the parser does not understand fail loudly instead of guessing', () => { + const job = (...lines) => ['jobs:', ' j:', ' name: J', ...lines].join('\n'); + assert.throws(() => parseWorkflowJobs(job(' strategy:', ' matrix:', ' include:', ' - a: 1')), /include is not supported/); + assert.throws(() => parseWorkflowJobs(job(' strategy:', ' matrix: ${{ fromJSON(x) }}')), /matrix expressions are not supported/); + assert.throws(() => parseWorkflowJobs(job(' strategy:', ' matrix:', ' shard:', ' - 1')), /only inline lists/); + assert.throws(() => parseWorkflowJobs('jobs:\n j:\n runs-on: x\n'), /job j has no name/); + assert.throws(() => parseWorkflowJobs('jobs:\n j:\n name: A\n j:\n name: B\n'), /duplicate job id j/); + assert.throws(() => parseWorkflowJobs('on: push\n'), /no top-level "jobs:"/); + assert.throws(() => jobInstanceNames(parseWorkflowJobs('jobs:\n j:\n name: "A ${{ inputs.x }}"\n').get('j')), /not a declared matrix axis/); +}); + +test('#622 AC1: every job of validate.yml is declared in ci-proof with its current name, and every declaration exists', () => { + assert.deepEqual(jobContractProblems(JOBS), []); + assert.doesNotThrow(() => resolveJobRules(JOBS)); + const declared = [...Object.values(JOB_RULES).flat().map((rule) => rule.job), ...Object.keys(UNCONSUMED_JOBS)]; + assert.deepEqual([...declared].sort(), [...JOBS.keys()].sort()); +}); + +test('#622 AC1: renaming ANY job name in validate.yml without touching the rules turns the contract red', () => { + const nameLines = [...TEXT.matchAll(/^ {4}name: .+$/gm)].map((m) => m[0]); + assert.equal(nameLines.length, JOBS.size, 'one job-level name line per job'); + for (const line of nameLines) { + const renamed = TEXT.replace(line, line.replace(/name: ("?)/, 'name: $1Переименовано ')); + const problems = jobContractProblems(parseWorkflowJobs(renamed, 'validate.yml (renamed)')); + assert.equal(problems.length, 1, `${line.trim()} → ${JSON.stringify(problems)}`); + assert.match(problems[0], /validate\.yml names it/); + } + const extra = TEXT.replace('\n proof:\n', '\n extra_job:\n name: Новая job\n runs-on: ubuntu-latest\n proof:\n'); + assert.match(jobContractProblems(parseWorkflowJobs(extra)).join('\n'), /extra_job: validate\.yml job is in neither/); +}); + +test('#622: consumed rules resolve to the exact instance names GitHub reports, within the 100-byte API limit', () => { + const rules = resolveJobRules(JOBS); + for (const [id, list] of Object.entries(rules)) { + for (const rule of list) { + assert.equal(rule.names.length, JOBS.get(rule.job).size, `${id}/${rule.job}: one name per matrix instance`); + for (const name of rule.names) { + assert.ok(Buffer.byteLength(name, 'utf8') <= 100, `${name}: GitHub Jobs API truncates names longer than 100 UTF-8 bytes`); + assert.doesNotMatch(name, /\$\{\{/, `${name}: unexpanded expression`); + } + } + } + const all = [...JOBS.values()].flatMap(jobInstanceNames); + assert.equal(new Set(all).size, all.length, 'job instance names are unique across the workflow'); +}); + +test('#622: the mutant prefix of validate-gate is the ci-proof contract and matches only changed_mutants', () => { + assert.equal(GATE_MUTANT_PREFIX, MUTANT_JOB_PREFIX); + for (const job of JOBS.values()) { + const hits = jobInstanceNames(job).filter((name) => name.startsWith(MUTANT_JOB_PREFIX)); + assert.equal(hits.length, job.id === 'changed_mutants' ? job.size : 0, job.id); + } +}); + +test('#622 AC2: shard totals written in names and scripts of a sharded job equal its matrix size', () => { + for (const job of JOBS.values()) { + if (!job.matrix?.shard) continue; + const start = TEXT.indexOf(`\n ${job.id}:\n`); + const next = TEXT.slice(start + 1).search(/\n {2}[A-Za-z0-9_-]+:\n/); + const body = TEXT.slice(start, next < 0 ? undefined : start + 1 + next); + const totals = [ + ...body.matchAll(/matrix\.shard \}\}(?:\/| из )(\d+)/g), + ...body.matchAll(/\$SHARD\/(\d+)/g), + ...body.matchAll(/SHARDS: '?(\d+)'?/g), + ].map((m) => Number(m[1])); + assert.ok(totals.length > 0, `${job.id}: shard total is written somewhere`); + for (const total of totals) assert.equal(total, job.size, `${job.id}: literal shard total ${total} vs matrix of ${job.size}`); + } +});