mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Every push to dev paid for the full browser trio and the backend suite, including commits that touch only documentation, workflows or process scripts — the bundle and the harness were byte-identical, so the runs proved nothing new. On 2026-08-19 alone that was roughly six pushes at about seven minutes each. The reuse key per heavy job is sourceFingerprint (src, demo fixtures, golden scenarios, build manifests) plus that job's own harness: smoke takes demo/smoke_*.mjs, golden takes demo/golden/** including baselines, performance_smoke takes demo/performance/**, backend takes tests_backend and the Python sources. A cache marker is written only by a successful run of the same key, so a hit proves a job with identical inputs already passed. scripts/** is deliberately outside every key: infrastructure work edits it constantly and reuse would never fire. This is not the path filter from the `changes` job, which stays disabled on dev on purpose: there the scope is guessed from paths and "green" means different things, here input equivalence is proven by a hash. And a release candidate always bumps the version, which is part of the fingerprint, so its keys are new by construction and the full gate set still runs before every beta and release. A waived job is announced with a notice and a run summary line rather than skipped in silence, and the marker save tolerates a concurrent identical run instead of reddening the job. Issue: #208 User-Visible: no
124 lines
6.4 KiB
JavaScript
124 lines
6.4 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 } 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: {
|
||
roots: ['tests_backend', 'custom_components', 'pytest.ini'],
|
||
// Внутри 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');
|
||
}
|
||
|
||
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('|')}> [--repo=<path>]\n`);
|
||
process.exit(2);
|
||
}
|
||
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);
|
||
}
|
||
}
|