mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +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;
|
||||
}
|
||||
+68
-21
@@ -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/);
|
||||
|
||||
+34
-1
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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: слияние точного кандидата. Таблица решений — на чистой
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]*\$\{\{/,
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user