ci: аттестовать локальную WSL-приёмку golden (#641)

Issue: #641
User-Visible: no
This commit is contained in:
Claude
2026-09-23 19:16:09 +03:00
parent 284644acc8
commit ee6ca4f3c9
59 changed files with 1199 additions and 431 deletions
+12 -3
View File
@@ -118,14 +118,21 @@ export function localEvidence(root = process.cwd(), { keys = null, rev = 'HEAD'
let baselineTree = null;
try { baselineTree = gitOut(root, ['rev-parse', `${rev}:${overlayDir}`]).trim() || null; } catch { baselineTree = null; }
const manifestPath = resolve(root, overlayDir, 'baselines-index.json');
const manifestSha256 = existsSync(manifestPath)
? createHash('sha256').update(readFileSync(manifestPath)).digest('hex') : null;
const manifestBytes = existsSync(manifestPath) ? readFileSync(manifestPath) : null;
const manifestSha256 = manifestBytes
? createHash('sha256').update(manifestBytes).digest('hex') : null;
let reviewedLocal = null;
if (manifestBytes) {
try { reviewedLocal = JSON.parse(manifestBytes).localAttestation?.sha256 ?? null; }
catch { reviewedLocal = null; }
}
return {
product: { tree: productTreeId(gitOut(root, ['ls-tree', '-r', '--full-tree', rev])) },
baselines: {
tree: baselineTree,
manifestSha256,
reviewedRun: baselineReviewedRun(gitOut(root, ['log', '-1', '--format=%B', rev])),
reviewedLocal,
},
keys: computed,
};
@@ -136,6 +143,7 @@ const EVIDENCE_FIELDS = [
['baselines.tree', (e) => e?.baselines?.tree ?? null],
['baselines.manifestSha256', (e) => e?.baselines?.manifestSha256 ?? null],
['baselines.reviewedRun', (e) => e?.baselines?.reviewedRun ?? null],
['baselines.reviewedLocal', (e) => e?.baselines?.reviewedLocal ?? null],
...REUSE_JOBS.map((job) => [`keys.${job}`, (e) => e?.keys?.[job]]),
];
@@ -498,7 +506,8 @@ if (isMainModule(import.meta.url)) {
writeFileSync(target, `${JSON.stringify(proof, null, 2)}\n`);
console.log(`CI proof: ${target} (${proof.requiredChecks.join(', ')})`);
console.log(`product tree ${evidence.product.tree.slice(0, 12)} · baselines ${evidence.baselines.tree?.slice(0, 12) || 'none'}`
+ ` · reviewed run ${evidence.baselines.reviewedRun || 'none'}`);
+ ` · reviewed run ${evidence.baselines.reviewedRun || 'none'}`
+ ` · reviewed local ${evidence.baselines.reviewedLocal?.slice(0, 12) || 'none'}`);
} else {
console.error('usage: ci-proof.mjs --emit=<proof.json> | --marker=<.reuse-marker>');
process.exitCode = 2;
+362
View File
@@ -0,0 +1,362 @@
#!/usr/bin/env node
/**
* Полный локальный golden-артефакт из WSL (#641).
*
* Съёмка допустима только от чистого опубликованного SHA ветки. Артефакт
* аттестует исходное дерево, WSL/ext4, пиновый Node/Playwright/Chromium,
* полный набор сцен, PNG-хеши и floor свидетелей. Приёмщик повторяет все
* проверки; финальный exact-SHA Validate в GitHub остаётся merge/release-гейтом.
*/
import { createHash } from 'node:crypto';
import { execFileSync, spawnSync } from 'node:child_process';
import {
createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync,
} from 'node:fs';
import { release as osRelease } from 'node:os';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from '../demo/golden/matrix.mjs';
import { GOLDEN_BASELINE_MANIFEST } from '../demo/golden/policy.mjs';
import {
goldenAcceptanceRefusal, goldenWitnessRefusal,
} from './golden-acceptance.mjs';
import { reportCaptureProvenance } from './capture-environment.mjs';
import { sourceFingerprint } from './source-fingerprint.mjs';
import { pinsFromSources } from './toolchain-pins.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
export const WSL_ATTESTATION_FILE = 'wsl-attestation.json';
export const WSL_ATTESTATION_SCHEMA = 'houseplan-golden-wsl/v1';
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
const canonical = (value) => {
if (Array.isArray(value)) return value.map(canonical);
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
};
export const canonicalJson = (value) => JSON.stringify(canonical(value));
export const objectSha256 = (value) => sha256(canonicalJson(value));
export async function fileSha256(path) {
const hash = createHash('sha256');
await new Promise((accept, reject) => {
const stream = createReadStream(path);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', accept);
stream.on('error', reject);
});
return hash.digest('hex');
}
const command = (cwd, executable, args) => execFileSync(executable, args, {
cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024,
}).trim();
const normalizeRepository = (remote) => {
const text = String(remote || '').trim().replace(/\.git$/, '');
return text.match(/github\.com[/:]([^/]+\/[^/]+)$/i)?.[1] || text;
};
export function repositorySnapshot(root = ROOT, run = command) {
const branch = run(root, 'git', ['symbolic-ref', '--quiet', '--short', 'HEAD']);
const commit = run(root, 'git', ['rev-parse', 'HEAD']);
const tree = run(root, 'git', ['rev-parse', 'HEAD^{tree}']);
const status = run(root, 'git', ['status', '--porcelain=v1', '--untracked-files=all']);
const remoteUrl = run(root, 'git', ['remote', 'get-url', 'origin']);
const remoteLine = run(root, 'git', ['ls-remote', '--exit-code', 'origin', `refs/heads/${branch}`]);
const remoteSha = remoteLine.split(/\s+/)[0] || '';
return {
repository: normalizeRepository(remoteUrl), branch, commit, tree, remoteSha,
clean: status === '', status,
};
}
export function repositoryRefusal(snapshot) {
if (!snapshot?.repository) return 'не удалось определить repository для WSL golden';
if (!snapshot?.branch) return 'WSL golden требует именованную ветку, detached HEAD запрещён';
if (!snapshot.clean || snapshot.status) return 'WSL golden требует чистое рабочее дерево';
if (!/^[0-9a-f]{40}$/.test(snapshot.commit || '') || snapshot.commit !== snapshot.remoteSha) {
return `локальный HEAD ${snapshot?.commit || 'unknown'} не совпадает с опубликованным origin/${snapshot?.branch || '?'} ${snapshot?.remoteSha || 'missing'}`;
}
if (!/^[0-9a-f]{40}$/.test(snapshot.tree || '')) return 'не удалось зафиксировать tree SHA исходного коммита';
return null;
}
export function runtimeEnvironment(root = ROOT, {
env = process.env, platform = process.platform, arch = process.arch,
release = osRelease(), run = command,
} = {}) {
let filesystem = null;
try { filesystem = run(root, 'stat', ['-f', '-c', '%T', '.']); } catch { /* refusal below */ }
const wsl = platform === 'linux' && (
Boolean(String(env.WSL_DISTRO_NAME || '').trim()) || /microsoft/i.test(String(release))
);
return {
platform, arch, kernel: String(release), wsl,
distro: String(env.WSL_DISTRO_NAME || '').trim() || null,
filesystem,
};
}
export function environmentRefusal(environment) {
if (environment?.platform !== 'linux' || !environment?.wsl) {
return 'аттестованная локальная съёмка разрешена только внутри WSL';
}
if (!environment.filesystem) return 'не удалось определить файловую систему рабочего дерева';
if (!environment.distro || !environment.kernel || !environment.arch) {
return 'WSL golden требует distro, kernel и architecture в паспорте среды';
}
if (/^(9p|drvfs|fuseblk|cifs|smb)/i.test(environment.filesystem)) {
return `рабочее дерево находится на ${environment.filesystem}; требуется Linux/ext4 clone внутри WSL`;
}
return null;
}
export async function toolchainSnapshot(root = ROOT) {
const pins = pinsFromSources();
const playwrightPackage = JSON.parse(readFileSync(resolve(root, 'node_modules/playwright/package.json'), 'utf8'));
const { chromium } = await import('playwright');
const chromiumExecutable = chromium.executablePath();
if (!existsSync(chromiumExecutable)) throw new Error(`Chromium не установлен: ${chromiumExecutable}`);
return {
pins,
node: process.versions.node,
npm: command(root, 'npm', ['--version']),
playwright: playwrightPackage.version,
chromiumExecutable,
chromiumExecutableSha256: await fileSha256(chromiumExecutable),
};
}
export function toolchainRefusal(toolchain, report = null) {
const failures = [];
if (String(toolchain?.node || '').split('.')[0] !== String(toolchain?.pins?.node || '')) failures.push('Node');
if (toolchain?.playwright !== toolchain?.pins?.playwright) failures.push('Playwright');
const browserVersion = toolchain?.pins?.chromium?.version;
if (report && browserVersion && !String(report.chromium || '').includes(browserVersion)) failures.push('Chromium');
if (!/^[0-9a-f]{64}$/.test(toolchain?.chromiumExecutableSha256 || '')) failures.push('Chromium executable');
return failures.length ? `toolchain расходится с CI: ${failures.join(', ')}` : null;
}
export function intentFromArgv(argv = process.argv.slice(2)) {
const list = (name) => {
const value = argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3) || '';
return [...new Set(value.split(',').map((item) => item.trim()).filter(Boolean))].sort();
};
const reason = argv.find((arg) => arg.startsWith('--reason='))?.slice('--reason='.length) || '';
return {
expectChange: list('expect-change'),
expectNew: list('expect-new'),
noWitnesses: argv.includes('--no-witnesses'),
reason,
};
}
const pngDimensions = (bytes) => {
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
if (bytes.length < 24 || !bytes.subarray(0, signature.length).equals(signature)) {
throw new Error('candidate is not a PNG');
}
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
};
const sameInventory = (left, right) => JSON.stringify([...left].sort())
=== JSON.stringify([...right].sort());
export function inspectArtifact({ root = ROOT, artifactRoot, report, intent }) {
if (report.matrixVersion !== GOLDEN_MATRIX_VERSION) {
throw new Error(`candidate matrix ${report.matrixVersion} != current ${GOLDEN_MATRIX_VERSION}`);
}
if (report.buildFingerprint !== sourceFingerprint(root)) {
throw new Error('candidate screenshots were not captured from the current frontend source');
}
const { provenance } = reportCaptureProvenance(report);
if (provenance.platform !== 'linux' || provenance.ci) {
throw new Error('WSL attestation requires a local Linux capture without CI provenance');
}
if (!Array.isArray(report.results)) throw new Error('candidate report has no scenario results');
const expected = GOLDEN_SCENARIOS.map((scenario) => scenario.id);
const reported = report.results.map((result) => result.id);
const actualRoot = resolve(artifactRoot, 'actual');
const files = existsSync(actualRoot)
? readdirSync(actualRoot).filter((name) => name.endsWith('.png')).map((name) => name.slice(0, -4))
: [];
if (!sameInventory(expected, reported) || !sameInventory(expected, files)) {
throw new Error('WSL artifact does not contain the complete current golden matrix');
}
const refusal = goldenAcceptanceRefusal(report.results, intent.expectChange, intent.expectNew);
if (refusal) throw new Error(refusal);
const previous = JSON.parse(readFileSync(resolve(root, 'demo/golden/baselines', GOLDEN_BASELINE_MANIFEST), 'utf8'));
const witness = goldenWitnessRefusal({
results: report.results,
sceneCount: GOLDEN_SCENARIOS.length,
declared: intent.expectChange,
declaredNew: intent.expectNew,
previousHashes: previous.scenarios || {},
skipWitnesses: intent.noWitnesses,
skipReason: intent.reason,
});
if (witness.refusal) throw new Error(witness.refusal);
const byId = new Map(report.results.map((result) => [result.id, result]));
const frames = expected.sort().map((id) => {
const result = byId.get(id);
if (result?.error || !['missing-baseline', 'passed', 'different'].includes(result?.status)) {
throw new Error(`review candidate has an invalid run status: ${id} (${result?.status || 'missing'})`);
}
const path = resolve(actualRoot, `${id}.png`);
const bytes = readFileSync(path);
const digest = sha256(bytes);
if (digest !== result.actualSha256) throw new Error(`candidate changed after capture: ${id}`);
return { id, sha256: digest, ...pngDimensions(bytes) };
});
const reportBytes = readFileSync(resolve(artifactRoot, 'golden-report.json'));
const reportSha256 = sha256(reportBytes);
const artifactSha256 = objectSha256({ reportSha256, frames });
return {
reportSha256, artifactSha256, frames,
witnesses: intent.noWitnesses
? { skipped: true, reason: intent.reason }
: { count: witness.witnesses.length, floor: witness.floor },
};
}
export async function createWslAttestation({
root = ROOT, artifactRoot = resolve(ROOT, 'artifacts/golden'), intent,
source = repositorySnapshot(root), environment = runtimeEnvironment(root),
toolchain = null, createdAt = new Date().toISOString(),
} = {}) {
toolchain ||= await toolchainSnapshot(root);
const repoProblem = repositoryRefusal(source);
if (repoProblem) throw new Error(repoProblem);
const envProblem = environmentRefusal(environment);
if (envProblem) throw new Error(envProblem);
const report = JSON.parse(readFileSync(resolve(artifactRoot, 'golden-report.json'), 'utf8'));
const toolProblem = toolchainRefusal(toolchain, report);
if (toolProblem) throw new Error(toolProblem);
const artifact = inspectArtifact({ root, artifactRoot, report, intent });
const payload = {
schema: WSL_ATTESTATION_SCHEMA,
kind: 'wsl-local',
command: 'npm run golden:wsl:capture',
createdAt,
source,
environment,
toolchain,
packageLockSha256: sha256(readFileSync(resolve(root, 'package-lock.json'))),
matrixVersion: report.matrixVersion,
buildFingerprint: report.buildFingerprint,
chromium: report.chromium,
intent,
...artifact,
};
return { ...payload, sha256: objectSha256(payload) };
}
const matchingSource = (attestation, current) => [
'repository', 'branch', 'commit', 'tree', 'remoteSha',
].every((key) => attestation?.source?.[key] === current?.[key]);
export async function verifyWslAttestation({
root = ROOT, artifactRoot, intent,
currentSource = null,
currentEnvironment = null,
currentToolchain = null,
} = {}) {
const path = resolve(artifactRoot, WSL_ATTESTATION_FILE);
if (!existsSync(path)) return null;
currentSource ||= repositorySnapshot(root);
currentEnvironment ||= runtimeEnvironment(root);
currentToolchain ||= await toolchainSnapshot(root);
const attestation = JSON.parse(readFileSync(path, 'utf8'));
if (attestation.schema !== WSL_ATTESTATION_SCHEMA || attestation.kind !== 'wsl-local') {
throw new Error(`unsupported WSL golden attestation: ${attestation.schema || 'missing'}`);
}
const { sha256: declaredSha, ...payload } = attestation;
if (!/^[0-9a-f]{64}$/.test(declaredSha || '') || objectSha256(payload) !== declaredSha) {
throw new Error('WSL golden attestation hash does not match its payload');
}
const repoProblem = repositoryRefusal(currentSource);
if (repoProblem) throw new Error(repoProblem);
const envProblem = environmentRefusal(currentEnvironment);
if (envProblem) throw new Error(envProblem);
if (!matchingSource(attestation, currentSource)) {
throw new Error('WSL golden attestation belongs to another repository, branch, commit or tree');
}
if (canonicalJson(attestation.intent) !== canonicalJson(intent)) {
throw new Error('acceptance intent differs from the reviewed WSL golden attestation');
}
if (attestation.packageLockSha256 !== sha256(readFileSync(resolve(root, 'package-lock.json')))) {
throw new Error('package-lock.json changed after the WSL golden capture');
}
const toolProblem = toolchainRefusal(currentToolchain);
if (toolProblem) throw new Error(toolProblem);
for (const key of ['node', 'npm', 'playwright', 'chromiumExecutableSha256']) {
if (currentToolchain[key] !== attestation.toolchain?.[key]) {
throw new Error(`toolchain changed after WSL golden capture: ${key}`);
}
}
const report = JSON.parse(readFileSync(resolve(artifactRoot, 'golden-report.json'), 'utf8'));
const artifact = inspectArtifact({ root, artifactRoot, report, intent });
for (const key of ['reportSha256', 'artifactSha256']) {
if (artifact[key] !== attestation[key]) throw new Error(`WSL golden artifact changed after capture: ${key}`);
}
if (canonicalJson(artifact.frames) !== canonicalJson(attestation.frames)
|| canonicalJson(artifact.witnesses) !== canonicalJson(attestation.witnesses)) {
throw new Error('WSL golden artifact frames or witnesses changed after capture');
}
return attestation;
}
const runNpm = (args) => {
const result = spawnSync('npm', args, { cwd: ROOT, stdio: 'inherit' });
if (result.error || result.status !== 0) process.exit(result.status ?? 1);
};
async function main() {
const intent = intentFromArgv();
const before = repositorySnapshot(ROOT);
const beforeProblem = repositoryRefusal(before);
if (beforeProblem) throw new Error(beforeProblem);
const environment = runtimeEnvironment(ROOT);
const envProblem = environmentRefusal(environment);
if (envProblem) throw new Error(envProblem);
const toolchain = await toolchainSnapshot(ROOT);
const toolProblem = toolchainRefusal(toolchain);
if (toolProblem) throw new Error(toolProblem);
const artifactRoot = resolve(ROOT, 'artifacts/golden');
rmSync(artifactRoot, { recursive: true, force: true });
mkdirSync(artifactRoot, { recursive: true });
runNpm(['run', 'bundle:sync']);
const built = repositorySnapshot(ROOT);
if (repositoryRefusal(built) || !matchingSource({ source: before }, built)) {
throw new Error('bundle:sync changed the published source tree; commit and push it before capture');
}
runNpm(['run', 'golden:capture']);
const after = repositorySnapshot(ROOT);
if (repositoryRefusal(after) || !matchingSource({ source: before }, after)) {
throw new Error('repository changed while the WSL golden artifact was captured');
}
const attestation = await createWslAttestation({
root: ROOT, artifactRoot, intent, source: after, environment, toolchain,
});
writeFileSync(resolve(artifactRoot, WSL_ATTESTATION_FILE), `${JSON.stringify(attestation, null, 2)}\n`);
console.log(`WSL golden artifact: ${artifactRoot}`);
console.log(`Baseline-Reviewed-Local: sha256:${attestation.sha256}`);
const flags = [
intent.expectChange.length ? `--expect-change=${intent.expectChange.join(',')}` : '',
intent.expectNew.length ? `--expect-new=${intent.expectNew.join(',')}` : '',
intent.noWitnesses ? '--no-witnesses' : '',
intent.reason ? `--reason=${JSON.stringify(intent.reason)}` : '',
].filter(Boolean).join(' ');
console.log(`Приёмка внутри WSL: npm run golden:accept -- --reviewed --from=artifacts/golden ${flags}`.trim());
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(`WSL golden: ${error.message}`);
process.exitCode = 1;
});
}
+3 -2
View File
@@ -139,6 +139,7 @@ export function makeCommit({
userVisible: one('User-Visible'),
release: one('Release'),
baselineReviewed: one('Baseline-Reviewed'),
baselineReviewedLocal: one('Baseline-Reviewed-Local'),
gates: one('Gates'),
// null = вызывающий не доказал содержимое diff. Для stable release это
// намеренно fail-closed: одного имени разрешённого version source мало.
@@ -245,8 +246,8 @@ export function evaluateCommit(c) {
warn(4, 'класс A/B без трейлера «User-Visible: yes|no»');
}
if (onlyD && !c.release && !c.baselineReviewed) {
fail(5, 'изменена только генерируемая часть (класс D) без «Release: vX.Y.Z» либо «Baseline-Reviewed: <ссылка>»');
if (onlyD && !c.release && !c.baselineReviewed && !c.baselineReviewedLocal) {
fail(5, 'изменена только генерируемая часть (класс D) без «Release: vX.Y.Z», «Baseline-Reviewed: <ссылка>» либо «Baseline-Reviewed-Local: sha256:<хеш>»');
}
if ((c.gates ?? '').toLowerCase() === 'light' && onlyD) {
+38 -9
View File
@@ -22,7 +22,10 @@ export const REVIEWED_GOLDEN_PROVENANCE_EXCEPTIONS = new Map([
const GOLDEN_PROVENANCE_ERRORS = new Set([
'golden baseline commit requires one Release trailer',
'golden baseline commit requires one Baseline-Reviewed trailer',
'golden baseline commit requires one Baseline-Reviewed or Baseline-Reviewed-Local trailer',
]);
const LOCAL_BASELINE = /^sha256:([0-9a-f]{64})$/;
const BASELINE_INDEX = 'demo/golden/baselines/baselines-index.json';
/** Git invokes commit-msg before it removes the editor template. Ignore the
* standard comment/scissors suffix exactly as Git will when it records the
@@ -48,7 +51,7 @@ export function terminalTrailers(message) {
return out;
}
export function validateCommitMessage(message, changedFiles = []) {
export function validateCommitMessage(message, changedFiles = [], { baselineIndex = undefined } = {}) {
const trailers = terminalTrailers(message);
const errors = [];
const issues = trailers.get('Issue') || [];
@@ -72,16 +75,34 @@ export function validateCommitMessage(message, changedFiles = []) {
if (changesGolden) {
const release = trailers.get('Release') || [];
const reviewed = trailers.get('Baseline-Reviewed') || [];
const reviewedLocal = trailers.get('Baseline-Reviewed-Local') || [];
if (release.length !== 1 || !release[0]) errors.push('golden baseline commit requires one Release trailer');
if (reviewed.length !== 1 || !reviewed[0]) {
errors.push('golden baseline commit requires one Baseline-Reviewed trailer');
const sources = Number(reviewed.length === 1 && !!reviewed[0])
+ Number(reviewedLocal.length === 1 && !!reviewedLocal[0]);
if (sources !== 1 || reviewed.length > 1 || reviewedLocal.length > 1) {
errors.push('golden baseline commit requires one Baseline-Reviewed or Baseline-Reviewed-Local trailer');
}
if (reviewedLocal.length === 1) {
const digest = reviewedLocal[0].match(LOCAL_BASELINE)?.[1] || null;
if (!digest) {
errors.push("Baseline-Reviewed-Local must be 'sha256:<64 lowercase hex>'");
} else if (baselineIndex !== undefined) {
try {
const index = typeof baselineIndex === 'string' ? JSON.parse(baselineIndex) : baselineIndex;
if (index?.localAttestation?.sha256 !== digest) {
errors.push('Baseline-Reviewed-Local does not match baselines-index.json localAttestation.sha256');
}
} catch {
errors.push('Baseline-Reviewed-Local requires a readable baselines-index.json');
}
}
}
}
return errors;
}
export function validateHistoricalCommit(commit, message, changedFiles = []) {
const errors = validateCommitMessage(message, changedFiles);
export function validateHistoricalCommit(commit, message, changedFiles = [], options = {}) {
const errors = validateCommitMessage(message, changedFiles, options);
if (!REVIEWED_GOLDEN_PROVENANCE_EXCEPTIONS.has(commit)) return errors;
return errors.filter((error) => !GOLDEN_PROVENANCE_ERRORS.has(error));
}
@@ -130,8 +151,8 @@ function assertDescendsFromBoundary(commit) {
}
}
function validateOne(label, message, files) {
const errors = validateCommitMessage(message, files);
function validateOne(label, message, files, options = {}) {
const errors = validateCommitMessage(message, files, options);
if (errors.length) throw new Error(`${label}:\n- ${errors.join('\n- ')}`);
}
@@ -145,7 +166,11 @@ function main(argv) {
const files = argv.includes('--staged')
? git(['diff', '--cached', '--name-only', '--diff-filter=ACMR']).split('\n').filter(Boolean)
: [];
validateOne('commit message', readFileSync(messageFile, 'utf8'), files);
let baselineIndex;
if (files.some((file) => /^demo\/golden\/baselines\/.*\.(png|json)$/.test(file))) {
try { baselineIndex = git(['show', `:${BASELINE_INDEX}`]); } catch { baselineIndex = null; }
}
validateOne('commit message', readFileSync(messageFile, 'utf8'), files, { baselineIndex });
}
const rangeAt = argv.indexOf('--range');
const githubRange = argv.includes('--github-range');
@@ -174,7 +199,11 @@ function main(argv) {
const message = git(['show', '-s', '--format=%B', commit]);
const files = git(['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', commit])
.split('\n').filter(Boolean);
const errors = validateHistoricalCommit(commit, message, files);
let baselineIndex;
if (files.some((file) => /^demo\/golden\/baselines\/.*\.(png|json)$/.test(file))) {
try { baselineIndex = git(['show', `${commit}:${BASELINE_INDEX}`]); } catch { baselineIndex = null; }
}
const errors = validateHistoricalCommit(commit, message, files, { baselineIndex });
if (errors.length) throw new Error(`${commit}:\n- ${errors.join('\n- ')}`);
}
}