perf: skip a heavy gate whose inputs are byte-identical to a green run

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
This commit is contained in:
Matysh
2026-08-19 23:33:44 +03:00
parent 11abc0292f
commit 72eae1059c
3 changed files with 481 additions and 5 deletions
+164 -5
View File
@@ -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 }}
+123
View File
@@ -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=<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);
}
}
+194
View File
@@ -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 });
}
});