fix: keep the docs fingerprint blind to the product version

Issue: #245
User-Visible: no
This commit is contained in:
Matysh
2026-08-22 19:02:11 +03:00
parent 99ceb1e57f
commit 4799c6be0c
6 changed files with 145 additions and 25 deletions
+5 -2
View File
@@ -3,7 +3,7 @@ import { createHash } from 'node:crypto';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { dirname, extname, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sourceFingerprint } from './source-fingerprint.mjs';
import { visualFingerprint } from './source-fingerprint.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const EXTERNAL = process.argv.includes('--external');
@@ -129,7 +129,10 @@ if (!existsSync(manifestPath)) {
} else {
const manifest = JSON.parse(canonicalText(manifestPath));
if (manifest.fixture !== 'synthetic-only') errors.push('screenshot manifest must declare synthetic-only fixture');
if (manifest.sourceFingerprint !== sourceFingerprint(ROOT))
// Версионно-нечувствительный отпечаток (#245): бамп версии не меняет ни одного
// пикселя, поэтому не обязан требовать пересъёмки — иначе каждый релизный
// коммит оставляет этот гейт красным.
if (manifest.sourceFingerprint !== visualFingerprint(ROOT))
errors.push('screenshot source fingerprint is stale; run npm run build && node demo/docs/capture.mjs');
const scriptPath = resolve(ROOT, 'demo/docs/capture.mjs');
if (manifest.captureScriptSha256 !== sha256(readFileSync(scriptPath)))
+13
View File
@@ -122,6 +122,19 @@ export const MUTANTS = [
replace: ' const span = centre;',
}],
},
{
id: 'docs-fingerprint-sees-product-version',
guard: 'node --test --test-name-pattern="не трогает отпечаток скриншотов" '
+ 'test/source-fingerprint.test.mjs',
because: 'номер версии продукта на скриншотах не нарисован, и если отпечаток документации '
+ 'снова начнёт его видеть, каждый релизный коммит будет оставлять job docs красным — '
+ 'красный гейт, который «всегда такой», перестают читать (#245)',
patches: [{
file: 'scripts/source-fingerprint.mjs',
find: ' ? (text) => text.split(version).join(\'0.0.0-product-version\')',
replace: ' ? (text) => text',
}],
},
{
id: 'smoke-select-drops-registered-link',
guard: 'node --test --test-name-pattern="держится на зарегистрированной связи" '
+49 -7
View File
@@ -20,27 +20,69 @@ const BUILD_INPUTS = [
'scripts/source-fingerprint.mjs',
];
/** Stable digest of frontend sources plus the files that control their build. */
export const sourceFingerprint = (root = process.cwd()) => {
const sourceRoot = resolve(root, 'src');
const hash = createHash('sha256');
const fingerprintFiles = (root) => {
const deterministicFixtureInputs = ['demo/fixtures', 'demo/golden']
.map((name) => resolve(root, name))
.filter(existsSync)
.flatMap(sourceFiles)
.filter((file) => file.endsWith('.mjs'));
const files = [
...sourceFiles(sourceRoot),
return [
...sourceFiles(resolve(root, 'src')),
...deterministicFixtureInputs,
...BUILD_INPUTS.map((name) => resolve(root, name)).filter(existsSync),
].sort((a, b) => relative(root, a).localeCompare(relative(root, b)));
};
const digest = (root, files, normalize) => {
const hash = createHash('sha256');
for (const file of files) {
hash.update(relative(root, file).replaceAll('\\', '/'));
hash.update('\0');
// Git-canonical text, independent of core.autocrlf. Otherwise the injected
// hash would make an otherwise identical Windows/Linux bundle differ.
hash.update(readFileSync(file, 'utf8').replace(/\r\n?/g, '\n'));
hash.update(normalize(readFileSync(file, 'utf8').replace(/\r\n?/g, '\n')));
hash.update('\0');
}
return hash.digest('hex');
};
/** Stable digest of frontend sources plus the files that control their build. */
export const sourceFingerprint = (root = process.cwd()) =>
digest(root, fingerprintFiles(root), (text) => text);
/** Номер версии продукта, как его знает package.json. */
const productVersion = (root) => {
const path = resolve(root, 'package.json');
if (!existsSync(path)) return '';
try {
const version = JSON.parse(readFileSync(path, 'utf8')).version;
return typeof version === 'string' ? version : '';
} catch {
return '';
}
};
/**
* Тот же корпус, но без номера версии продукта (#245).
*
* Зачем понадобился второй отпечаток. Релизный коммит бампает версию в трёх
* местах разом: `package.json`, `package-lock.json` и `CARD_VERSION` в
* `src/houseplan-card.ts`. Для бандла и для переиспользования гейтов это
* настоящее изменение — бандл действительно другой, и его нельзя считать
* свежим; отпечаток обязан ехать. А для скриншотов документации номер версии
* не значит ничего: он на них не нарисован. Общий отпечаток на два разных
* вопроса давал дефект, из-за которого КАЖДЫЙ релизный коммит оставлял job
* `docs` красным: пересъёмка шла до бампа, и записанное значение не совпадало
* с закоммиченным деревом.
*
* Нормализуется ровно строка версии продукта — не любое похожее число:
* версия зависимости в `package-lock.json` остаётся частью отпечатка, иначе
* обновление зависимости перестало бы требовать пересъёмки.
*/
export const visualFingerprint = (root = process.cwd()) => {
const version = productVersion(root);
const normalize = version
? (text) => text.split(version).join('0.0.0-product-version')
: (text) => text;
return digest(root, fingerprintFiles(root), normalize);
};