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
+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 });
}
});