mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Rebase resolution: dev's #392 introduced tests_backend/requirements.txt (python 3.14, phcc 0.13.357, homeassistant 2026.8.3) — exactly the single-source-of-pins AC of this issue, so the duplicate requirements_test.txt is dropped and both workflows keep installing from the #392 file; ruff is added there for the lint step. The backend reuse key no longer names the dead file (tests_backend/ as a root already covers the new one); ARCHITECTURE.md points at the real path. User-Visible: no Issue: #42
209 lines
12 KiB
JavaScript
209 lines
12 KiB
JavaScript
// Ключ переиспользования результата тяжёлой job (issue #208).
|
||
//
|
||
// Тяжёлые job Validate — smoke, golden, performance_smoke, backend — прогонялись
|
||
// на каждый push в dev, включая коммиты в документацию, workflows и процессные
|
||
// скрипты, где бандл и оснастка побайтово те же. Ключ здесь отвечает на один
|
||
// вопрос: «менялось ли хоть что-то, от чего результат этой job зависит».
|
||
//
|
||
// Ключ = sourceFingerprint (входы поведения: src/**, demo/fixtures,
|
||
// demo/golden/*.mjs, package.json, lock, rollup, tsconfig) ПЛЮС хеш собственной
|
||
// оснастки job. Совпал ключ с прогоном, который завершился успешно, — повторять
|
||
// нечего; не совпал — гоняем.
|
||
//
|
||
// Почему это не фильтры путей из job `changes` (на dev они намеренно
|
||
// отключены): там объём прогона угадывается по путям, и «зелёный» начинает
|
||
// значить разное. Здесь эквивалентность входов ДОКАЗАНА хешем, а маркер успеха
|
||
// пишет только успешный прогон с тем же ключом.
|
||
//
|
||
// Свойство, которое стоит знать: релизный кандидат (бета или стабильный релиз)
|
||
// бампает версию, а `CARD_VERSION` и `package.json` входят в sourceFingerprint.
|
||
// Значит ключ кандидата заведомо новый и полный набор гейтов прогоняется всегда.
|
||
// Переиспользование физически не может ослабить релизный гейт.
|
||
|
||
import { createHash } from 'node:crypto';
|
||
import { appendFileSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||
import { relative, resolve } from 'node:path';
|
||
|
||
import { sourceFingerprint } from './source-fingerprint.mjs';
|
||
|
||
/**
|
||
* Оснастка каждой job: файлы, от которых её результат зависит помимо входов
|
||
* поведения. `scripts/**` целиком сюда не берётся намеренно — он меняется
|
||
* часто и почти всегда не в той части, которую job исполняет; берутся только
|
||
* фактически исполняемые файлы (см. package.json).
|
||
*/
|
||
export const HARNESS = {
|
||
smoke: {
|
||
roots: ['demo'],
|
||
keep: (rel) => /^demo\/smoke_[^/]+\.mjs$/.test(rel),
|
||
},
|
||
golden: {
|
||
// demo/golden/** целиком: и сценарии, и эталоны — эталон тоже вход
|
||
// сравнения, его подмена обязана менять ключ.
|
||
roots: ['demo/golden'],
|
||
keep: () => true,
|
||
},
|
||
performance_smoke: {
|
||
roots: ['demo'],
|
||
keep: (rel) => /^demo\/performance\//.test(rel)
|
||
|| /^demo\/benchmark_(glow|large_house)\.mjs$/.test(rel),
|
||
},
|
||
backend: {
|
||
// #42: порог покрытия и конфиг линтеров — прямые входы job
|
||
// (`head -1 baseline` в шаге сравнения; ruff/mypy читают pyproject): их
|
||
// изменение без правок тестов обязано сбрасывать реюз, иначе baseline-bump
|
||
// молча пройдёт по старому зелёному маркеру. Пины зависимостей
|
||
// (tests_backend/requirements.txt, #392) покрыты корнем tests_backend.
|
||
roots: ['tests_backend', 'custom_components', 'pytest.ini',
|
||
'scripts/backend-coverage-baseline.txt', 'pyproject.toml'],
|
||
// Внутри custom_components/** значим только Python: собранный фронтенд
|
||
// лежит там же и меняется от любой сборки, а backend его не исполняет.
|
||
keep: (rel) => !rel.startsWith('custom_components/') || rel.endsWith('.py'),
|
||
},
|
||
};
|
||
|
||
export const JOBS = Object.keys(HARNESS);
|
||
|
||
/** Все файлы под путём (файл — сам путь), относительными путями через «/». */
|
||
const walk = (root, entry) => {
|
||
const abs = resolve(root, entry);
|
||
if (!existsSync(abs)) return [];
|
||
if (!statSync(abs).isDirectory()) return [relative(root, abs).replaceAll('\\', '/')];
|
||
return readdirSync(abs).sort().flatMap((name) =>
|
||
walk(root, relative(root, resolve(abs, name)).replaceAll('\\', '/')));
|
||
};
|
||
|
||
/** Файлы оснастки job в порядке, не зависящем от файловой системы. */
|
||
export function harnessFiles(root, job) {
|
||
const spec = HARNESS[job];
|
||
if (!spec) throw new Error(`неизвестная job: ${job}. Известны: ${JOBS.join(', ')}`);
|
||
const seen = new Set();
|
||
for (const entry of spec.roots) {
|
||
for (const rel of walk(root, entry)) if (spec.keep(rel)) seen.add(rel);
|
||
}
|
||
return [...seen].sort((a, b) => a.localeCompare(b));
|
||
}
|
||
|
||
/**
|
||
* Ключ переиспользования. Пустая оснастка не молчит: она означала бы, что job
|
||
* зависит только от входов поведения, и такую подмену лучше заметить.
|
||
*/
|
||
export function reuseKey(root, job) {
|
||
const files = harnessFiles(root, job);
|
||
if (!files.length) throw new Error(`оснастка job ${job} пуста — проверьте HARNESS`);
|
||
const hash = createHash('sha256');
|
||
hash.update(`job:${job}\0`);
|
||
hash.update(`source:${sourceFingerprint(root)}\0`);
|
||
for (const rel of files) {
|
||
hash.update(rel);
|
||
hash.update('\0');
|
||
// Текст канонизируется по переводам строк, бинарное берётся как есть:
|
||
// иначе Windows и Linux дали бы разные ключи на одном дереве.
|
||
const raw = readFileSync(resolve(root, rel));
|
||
const text = raw.includes(0) ? raw : Buffer.from(raw.toString('utf8').replace(/\r\n?/g, '\n'));
|
||
hash.update(text);
|
||
hash.update('\0');
|
||
}
|
||
return hash.digest('hex');
|
||
}
|
||
|
||
/**
|
||
* Маркер падения (issue #386). Маркер успеха отвечает на вопрос «прогонять ли
|
||
* снова», этот — на другой: «с какого коммита эта job красная».
|
||
*
|
||
* Разница видна в почте. Упавший прогон маркера успеха не пишет, поэтому
|
||
* следующий коммит гонит job заново и падает так же — и письмо «Run failed»
|
||
* называет того, кто пушнул следующим. 29 августа так был назван `0f7b6f5`,
|
||
* документ ревью, который не мог изменить ни одного пикселя: сцену без эталона
|
||
* добавил `dbbe94ae` четырьмя коммитами раньше.
|
||
*
|
||
* Ключ здесь тот же, что у маркера успеха, а значит совпадение ключа доказывает
|
||
* равенство входов. Само падение при этом не кэшируется: job прогоняется всегда,
|
||
* иначе починка осталась бы незамеченной. Меняется только формулировка.
|
||
*/
|
||
export function parseFailureMarker(text) {
|
||
if (typeof text !== 'string' || !text.trim()) return null;
|
||
const field = (name) => text.match(new RegExp(`^${name}:\\s*(.+)$`, 'm'))?.[1]?.trim() || '';
|
||
const sha = field('SHA');
|
||
return { sha, runUrl: field('прогон') };
|
||
}
|
||
|
||
const short = (sha) => (typeof sha === 'string' && /^[0-9a-f]{7,}$/i.test(sha) ? sha.slice(0, 8) : '');
|
||
|
||
/**
|
||
* Формулировка для упавшей job. `prior` — разобранный маркер предыдущего
|
||
* падения с тем же ключом либо null.
|
||
*/
|
||
export function inheritedFailureNote({ job, sha, runUrl, prior }) {
|
||
const here = short(sha);
|
||
if (!prior) {
|
||
return {
|
||
first: true,
|
||
notice: `${job} упала впервые на этих входах — причина в этом коммите${here ? ` (${here})` : ''}`,
|
||
summary: [
|
||
`### ${job}: падение на новых входах`,
|
||
`Предыдущего падения с тем же ключом переиспользования не было — изменения этого коммита${here ? ` (\`${here}\`)` : ''} и есть причина.`,
|
||
],
|
||
};
|
||
}
|
||
const first = short(prior.sha);
|
||
const since = first ? `с \`${first}\`` : 'с более раннего прогона (SHA в маркере не записан)';
|
||
const link = prior.runUrl ? ` Первый такой прогон: ${prior.runUrl}` : '';
|
||
return {
|
||
first: false,
|
||
notice: `${job} красная на тех же входах ${first ? `с ${first}` : '(SHA первого падения не записан)'}`
|
||
+ ` — этот коммит её не ронял`,
|
||
summary: [
|
||
`### ${job}: унаследованное падение`,
|
||
`Входы этой job побайтово те же, что в первом падении ${since}: этот коммит её не ронял.`,
|
||
'Красноту снимает либо приёмка эталонов, либо правка того, что job проверяет —'
|
||
+ ' и то и другое меняет ключ переиспользования.' + link,
|
||
],
|
||
};
|
||
}
|
||
|
||
function main(argv) {
|
||
const job = (argv.find((a) => a.startsWith('--job=')) || '').slice('--job='.length);
|
||
const root = (argv.find((a) => a.startsWith('--repo=')) || '').slice('--repo='.length) || process.cwd();
|
||
if (!job) {
|
||
process.stderr.write(`usage: gate-reuse.mjs --job=<${JOBS.join('|')}> [--note --marker=<path>] [--repo=<path>]\n`);
|
||
process.exit(2);
|
||
}
|
||
// Режим объяснения падения (#386): ключ уже посчитан job `reuse`, считать его
|
||
// здесь заново нельзя — дерево то же, но лишний проход по эталонам стоит
|
||
// секунд, а расхождение с ключом кэша было бы незаметным и вредным.
|
||
if (argv.includes('--note')) {
|
||
const marker = (argv.find((a) => a.startsWith('--marker=')) || '').slice('--marker='.length)
|
||
|| '.fail-marker';
|
||
const prior = existsSync(marker) ? parseFailureMarker(readFileSync(marker, 'utf8')) : null;
|
||
const sha = process.env.GITHUB_SHA || '';
|
||
const runUrl = process.env.RUN_URL || '';
|
||
const note = inheritedFailureNote({ job, sha, runUrl, prior });
|
||
process.stdout.write(`::notice::${note.notice} (#386)\n`);
|
||
if (process.env.GITHUB_STEP_SUMMARY) {
|
||
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${note.summary.join('\n\n')}\n`);
|
||
}
|
||
// Первое падение оставляет маркер следующим прогонам; унаследованное не
|
||
// трогает файл, чтобы SHA первопричины не съехал на свидетеля.
|
||
if (note.first) {
|
||
writeFileSync(marker, `${job} упала\nSHA: ${sha}\nпрогон: ${runUrl}\n`);
|
||
}
|
||
if (process.env.GITHUB_OUTPUT) {
|
||
appendFileSync(process.env.GITHUB_OUTPUT, `first=${note.first}\n`);
|
||
}
|
||
return;
|
||
}
|
||
const key = reuseKey(root, job);
|
||
process.stdout.write(`${key}\n`);
|
||
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `key=${key}\n`);
|
||
}
|
||
|
||
if (process.argv[1] && process.argv[1].endsWith('gate-reuse.mjs')) {
|
||
try {
|
||
main(process.argv.slice(2));
|
||
} catch (err) {
|
||
process.stderr.write(`gate-reuse: ${err.message}\n`);
|
||
process.exit(1);
|
||
}
|
||
}
|