diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index e5e971fd..a77dfdc3 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -116,6 +116,98 @@ jobs: echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')" } >> "$GITHUB_OUTPUT" + # Переиспользование результата тяжёлой job (#208). Ключ = входы поведения + # (sourceFingerprint: src/**, demo/fixtures, demo/golden/*.mjs, манифесты + # сборки) ПЛЮС оснастка именно этой job. Маркер в кэше пишет только успешный + # прогон с тем же ключом, поэтому попадание доказывает: job с побайтово теми + # же входами уже завершилась успешно. + # + # Это НЕ фильтр путей из job `changes` (на dev они отключены намеренно): там + # объём прогона угадывается по путям и «зелёный» начинает значить разное, + # здесь эквивалентность входов доказана хешем. + # + # Свойство, снимающее главный риск: релизный кандидат бампает версию, а + # CARD_VERSION и package.json входят в фингерпринт, поэтому ключи кандидата + # заведомо новые и полный набор гейтов перед бетой и релизом идёт всегда. + reuse: + runs-on: ubuntu-latest + outputs: + smoke: ${{ steps.probe.outputs.smoke }} + golden: ${{ steps.probe.outputs.golden }} + performance_smoke: ${{ steps.probe.outputs.performance_smoke }} + backend: ${{ steps.probe.outputs.backend }} + smoke_key: ${{ steps.keys.outputs.smoke }} + golden_key: ${{ steps.keys.outputs.golden }} + performance_smoke_key: ${{ steps.keys.outputs.performance_smoke }} + backend_key: ${{ steps.keys.outputs.backend }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: { node-version: 22 } + - name: Ключи переиспользования + id: keys + run: | + for job in smoke golden performance_smoke backend; do + key=$(node scripts/gate-reuse.mjs --job="$job") + echo "$job=$key" >> "$GITHUB_OUTPUT" + echo "$job: $key" + done + # lookup-only: маркер только проверяется, но не восстанавливается — + # сохранять его в этой job нечего, она ничего не прогоняла. + - name: Маркер smoke + id: m_smoke + uses: actions/cache/restore@v6 + with: + path: .reuse-marker + key: reuse-smoke-${{ steps.keys.outputs.smoke }} + lookup-only: true + - name: Маркер golden + id: m_golden + uses: actions/cache/restore@v6 + with: + path: .reuse-marker + key: reuse-golden-${{ steps.keys.outputs.golden }} + lookup-only: true + - name: Маркер performance_smoke + id: m_perf + uses: actions/cache/restore@v6 + with: + path: .reuse-marker + key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }} + lookup-only: true + - name: Маркер backend + id: m_backend + uses: actions/cache/restore@v6 + with: + path: .reuse-marker + key: reuse-backend-${{ steps.keys.outputs.backend }} + lookup-only: true + - name: Что переиспользуем + id: probe + env: + SMOKE: ${{ steps.m_smoke.outputs.cache-hit }} + GOLDEN: ${{ steps.m_golden.outputs.cache-hit }} + PERF: ${{ steps.m_perf.outputs.cache-hit }} + BACKEND: ${{ steps.m_backend.outputs.cache-hit }} + run: | + # Пропуск обязан быть громким: молчаливый skip — тот самый тихий + # успех, который уже дважды стоил нам дня (#171, #207). + waive() { + if [ "$2" = "true" ]; then + echo "$1=true" >> "$GITHUB_OUTPUT" + echo "::notice::$1 не прогоняется: входы побайтово те же, что в предыдущем успешном прогоне (#208)" + echo "- **$1** переиспользована: входы не менялись" >> "$GITHUB_STEP_SUMMARY" + else + echo "$1=false" >> "$GITHUB_OUTPUT" + echo "- $1: прогоняется" >> "$GITHUB_STEP_SUMMARY" + fi + } + echo "### Переиспользование гейтов (#208)" >> "$GITHUB_STEP_SUMMARY" + waive smoke "$SMOKE" + waive golden "$GOLDEN" + waive performance_smoke "$PERF" + waive backend "$BACKEND" + hacs: needs: changes if: needs.changes.outputs.integration == 'true' @@ -160,7 +252,8 @@ jobs: smoke: # Gated on `frontend` so a typecheck failure does not burn browser minutes. - needs: frontend + needs: [frontend, reuse] + if: needs.reuse.outputs.smoke != 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -206,11 +299,28 @@ jobs: with: name: smoke-logs path: /tmp/smoke-logs + # Маркер пишется последним шагом: он существует только если всё выше + # прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job. + - name: Записать маркер успеха + run: | + printf '%s\n' "smoke прогнана успешно" \ + "SHA: ${{ github.sha }}" \ + "прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + > .reuse-marker + - uses: actions/cache/save@v6 + # Гонка двух прогонов с одинаковым ключом даёт «Cache already exists». + # Это не отказ гейта: работа выполнена, маркер уже записал сосед — job + # краснеть из-за этого не должна. + continue-on-error: true + with: + path: .reuse-marker + key: reuse-smoke-${{ needs.reuse.outputs.smoke_key }} golden: # Deterministic visual correctness stays in every prerelease gate: it is # inexpensive and catches a different class of regressions than timings. - needs: frontend + needs: [frontend, reuse] + if: needs.reuse.outputs.golden != 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -251,11 +361,28 @@ jobs: with: name: golden-images path: artifacts/golden + # Маркер пишется последним шагом: он существует только если всё выше + # прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job. + - name: Записать маркер успеха + run: | + printf '%s\n' "golden прогнана успешно" \ + "SHA: ${{ github.sha }}" \ + "прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + > .reuse-marker + - uses: actions/cache/save@v6 + # Гонка двух прогонов с одинаковым ключом даёт «Cache already exists». + # Это не отказ гейта: работа выполнена, маркер уже записал сосед — job + # краснеть из-за этого не должна. + continue-on-error: true + with: + path: .reuse-marker + key: reuse-golden-${{ needs.reuse.outputs.golden_key }} performance_smoke: # Candidate-only catastrophic-regression guard for ordinary pushes and # prereleases. The expensive same-runner comparison lives in performance.yml. - needs: frontend + needs: [frontend, reuse] + if: needs.reuse.outputs.performance_smoke != 'true' runs-on: ubuntu-latest # 15 минут не хватало, когда установка браузера шла через apt: замер # начинался на исходе окна (#206). Запас на холодный кэш — при попадании @@ -296,10 +423,26 @@ jobs: with: name: performance-smoke path: artifacts/performance-smoke + # Маркер пишется последним шагом: он существует только если всё выше + # прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job. + - name: Записать маркер успеха + run: | + printf '%s\n' "performance_smoke прогнана успешно" \ + "SHA: ${{ github.sha }}" \ + "прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + > .reuse-marker + - uses: actions/cache/save@v6 + # Гонка двух прогонов с одинаковым ключом даёт «Cache already exists». + # Это не отказ гейта: работа выполнена, маркер уже записал сосед — job + # краснеть из-за этого не должна. + continue-on-error: true + with: + path: .reuse-marker + key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }} backend: - needs: changes - if: needs.changes.outputs.backend == 'true' + needs: [changes, reuse] + if: needs.changes.outputs.backend == 'true' && needs.reuse.outputs.backend != 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -312,3 +455,19 @@ jobs: - run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend - name: Backend unit tests (pure + HA harness) run: python -m pytest tests_backend/ -q + # Маркер пишется последним шагом: он существует только если всё выше + # прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job. + - name: Записать маркер успеха + run: | + printf '%s\n' "backend прогнана успешно" \ + "SHA: ${{ github.sha }}" \ + "прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + > .reuse-marker + - uses: actions/cache/save@v6 + # Гонка двух прогонов с одинаковым ключом даёт «Cache already exists». + # Это не отказ гейта: работа выполнена, маркер уже записал сосед — job + # краснеть из-за этого не должна. + continue-on-error: true + with: + path: .reuse-marker + key: reuse-backend-${{ needs.reuse.outputs.backend_key }} diff --git a/scripts/gate-reuse.mjs b/scripts/gate-reuse.mjs new file mode 100644 index 00000000..0618a328 --- /dev/null +++ b/scripts/gate-reuse.mjs @@ -0,0 +1,123 @@ +// Ключ переиспользования результата тяжёлой 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=]\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); + } +} diff --git a/test/gate-reuse.test.mjs b/test/gate-reuse.test.mjs new file mode 100644 index 00000000..957ef83b --- /dev/null +++ b/test/gate-reuse.test.mjs @@ -0,0 +1,194 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { HARNESS, JOBS, harnessFiles, reuseKey } from '../scripts/gate-reuse.mjs'; + +/** + * Дерево, минимально достаточное для sourceFingerprint плюс оснастка каждой + * тяжёлой job. Реальные каталоги, а не подмены: ключ обязан отражать файловую + * систему так же, как в CI. + */ +const makeTree = () => { + const dir = mkdtempSync(join(tmpdir(), 'hp-reuse-')); + const put = (rel, text) => { + const full = join(dir, rel); + mkdirSync(join(full, '..'), { recursive: true }); + writeFileSync(full, text); + }; + put('package.json', '{"name":"x","version":"1.0.0"}\n'); + put('package-lock.json', '{"lockfileVersion":3}\n'); + put('rollup.config.mjs', 'export default {};\n'); + put('tsconfig.json', '{}\n'); + put('scripts/source-fingerprint.mjs', '// pinned by the real repo copy\n'); + put('src/card.ts', "export const CARD_VERSION = '1.0.0';\n"); + put('demo/fixtures/one.mjs', 'export const fixture = 1;\n'); + put('demo/smoke_alpha.mjs', 'console.log(1);\n'); + put('demo/smoke_beta.mjs', 'console.log(2);\n'); + put('demo/benchmark_glow.mjs', 'export const glow = 1;\n'); + put('demo/golden/run.mjs', 'export const run = 1;\n'); + put('demo/golden/baselines/one.png', Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01])); + put('demo/performance/compare.mjs', 'export const cmp = 1;\n'); + put('tests_backend/test_pure.py', 'def test_x():\n assert True\n'); + put('custom_components/houseplan/store.py', 'VERSION = 1\n'); + put('custom_components/houseplan/frontend/houseplan-card.js', 'built bundle\n'); + put('pytest.ini', '[pytest]\n'); + put('docs/STATUS.md', 'status\n'); + return { dir, put }; +}; + +const keys = (dir) => Object.fromEntries(JOBS.map((job) => [job, reuseKey(dir, job)])); + +test('every heavy job has a non-empty harness and its own key', () => { + const { dir } = makeTree(); + try { + assert.deepEqual(JOBS, ['smoke', 'golden', 'performance_smoke', 'backend']); + for (const job of JOBS) assert.ok(harnessFiles(dir, job).length > 0, job); + // Ключи различаются между job: иначе правка чужой оснастки гасила бы чужой + // прогон, а совпадение ключей маскировало бы это как «то же самое». + const set = new Set(Object.values(keys(dir))); + assert.equal(set.size, JOBS.length); + assert.throws(() => reuseKey(dir, 'frontend'), /неизвестная job/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('documentation, workflows and unit tests leave every key untouched (#208)', () => { + // Именно этот случай и оплачивал полный прогон: коммит, не меняющий ни одного + // входа поведения и ни одной оснастки. + const { dir, put } = makeTree(); + try { + const before = keys(dir); + put('docs/STATUS.md', 'status changed\n'); + put('.github/workflows/validate.yml', 'name: Validate\n'); + put('test/some.test.mjs', 'import test from "node:test";\n'); + put('scripts/process-gate.mjs', '// unrelated tooling\n'); + put('PROCESS.md', 'canon\n'); + assert.deepEqual(keys(dir), before); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a behaviour input changes every key, including a version bump (#208)', () => { + const { dir, put } = makeTree(); + try { + const before = keys(dir); + put('src/card.ts', "export const CARD_VERSION = '1.0.0';\n// behaviour\n"); + for (const job of JOBS) assert.notEqual(reuseKey(dir, job), before[job], job); + + // Релизный кандидат бампает версию, поэтому его ключи заведомо новые и + // полный набор гейтов прогоняется всегда — переиспользование не может + // ослабить релизный гейт. + const bumped = keys(dir); + put('src/card.ts', "export const CARD_VERSION = '1.1.0';\n// behaviour\n"); + put('package.json', '{"name":"x","version":"1.1.0"}\n'); + for (const job of JOBS) assert.notEqual(reuseKey(dir, job), bumped[job], job); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('harness edits are isolated to their own job (#208)', () => { + const { dir, put } = makeTree(); + const only = (changed) => { + const before = keys(dir); + return (apply) => { + apply(); + const after = keys(dir); + for (const job of JOBS) { + if (job === changed) assert.notEqual(after[job], before[job], `${job} должен меняться`); + else assert.equal(after[job], before[job], `${job} меняться не должен`); + } + }; + }; + + try { + only('smoke')(() => put('demo/smoke_alpha.mjs', 'console.log(3);\n')); + // Эталон — вход сравнения, его подмена обязана менять ключ golden. + only('golden')(() => put('demo/golden/baselines/one.png', + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x02]))); + only('performance_smoke')(() => put('demo/performance/compare.mjs', 'export const cmp = 2;\n')); + only('backend')(() => put('custom_components/houseplan/store.py', 'VERSION = 2\n')); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('the built bundle is outside every key — each job builds it itself (#208)', () => { + const { dir, put } = makeTree(); + try { + const before = keys(dir); + put('custom_components/houseplan/frontend/houseplan-card.js', 'rebuilt bundle\n'); + put('dist/houseplan-card.js', 'rebuilt bundle\n'); + put('demo/srv/assets/houseplan-card.js', 'rebuilt bundle\n'); + assert.deepEqual(keys(dir), before); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('the key is canonical across line endings', () => { + const { dir, put } = makeTree(); + try { + const before = keys(dir); + put('demo/smoke_alpha.mjs', 'console.log(1);\r\n'); + assert.deepEqual(keys(dir), before, 'CRLF не должен рождать другой ключ'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('the CLI prints the key and writes it to GITHUB_OUTPUT', (t) => { + const { dir } = makeTree(); + const cli = fileURLToPath(new URL('../scripts/gate-reuse.mjs', import.meta.url)); + const out = join(dir, 'gh-output'); + writeFileSync(out, ''); + try { + const run = (args, env = {}) => spawnSync(process.execPath, [cli, ...args], + { encoding: 'utf8', env: { ...process.env, ...env } }); + + const ok = run([`--repo=${dir}`, '--job=golden'], { GITHUB_OUTPUT: out }); + assert.equal(ok.status, 0, ok.stdout + ok.stderr); + const printed = ok.stdout.trim(); + assert.match(printed, /^[0-9a-f]{64}$/); + assert.equal(printed, reuseKey(dir, 'golden')); + + // Значение уезжает в GITHUB_OUTPUT ровно в том виде, который читает шаг. + // Раньше здесь стоял динамический import внутри синхронной функции — CLI + // падал бы именно в CI, где GITHUB_OUTPUT задан. + assert.equal(readFileSync(out, 'utf8'), `key=${printed}\n`); + + // Без --job работать нельзя: пустой ключ совпал бы со всем подряд. + assert.equal(run([`--repo=${dir}`]).status, 2); + const unknown = run([`--repo=${dir}`, '--job=frontend']); + assert.equal(unknown.status, 1); + assert.match(unknown.stderr, /неизвестная job/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('HARNESS keeps scripts/** out of the keys on purpose', () => { + // Инфраструктурная работа правит scripts/** постоянно. Если бы каталог + // целиком попал в ключ, переиспользование не срабатывало бы никогда — ровно + // тот случай, ради которого #208 и заводился. + const { dir, put } = makeTree(); + try { + put('scripts/process-gate.mjs', '// tooling\n'); + for (const [job, spec] of Object.entries(HARNESS)) { + assert.ok(!spec.roots.includes('scripts'), `${job}: scripts в корнях обхода`); + assert.ok( + !harnessFiles(dir, job).some((rel) => rel.startsWith('scripts/')), + `${job}: scripts попал в оснастку`, + ); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +});