mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
infra(process): bundle and review index change only on the way to dev (#657)
Решения владельца: 1б — индекс ревью не пересобирается в ветке задачи, только коммитами, идущими в dev; 2б — бандл меняет только кандидат беты/релиза, стенд dev берёт его из артефакта Validate. - scripts/bundle-policy.mjs: коммит, трогающий dist/** или custom_components/houseplan/frontend/**, обязан нести Release: (хук commit-msg и история в CI через validate-commit-provenance; коммиты с датой автора до 2026-09-27 не судятся); --verify судит целостность свежей сборки всегда, побайтовую сверку с закоммиченной копией — только на коммите, меняющем бандл, или кандидате; --clean. - release-prerelease: публикация отказывает, если отпечаток исходников в закоммиченном манифесте не равен отпечатку дерева (хотфикс поверх кандидата без пересборки). - bundle-sync: по умолчанию только demo/srv/assets; --release (npm run bundle:release) — ещё и custom_components. - rebase-on-dev: конфликт в бандле берёт копию dev, без пересборки и amend. - validate.yml: job dev_build публикует card-bundle головы dev в сиротскую ветку dev-build (scripts/dev-build.mjs); стенд накладывает её demo/stand/update-dev-bundle.sh. - _process.yml: индекс ревью больше не пересобирается при приведении к dev и при публикации документа в ветку задачи. - golden-wsl-artifact/golden-container: сборка перед съёмкой не считается правкой источника, после — bundle:clean. - test/bundle-tree-committed: судит закоммиченный снимок, не диск. - 11 мутантов в реестре; PROCESS/AGENTS/DEVELOPMENT/AUTHOR/REVIEWER. Issue: #657 User-Visible: no
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Бандл в `dev` меняется только релизным кандидатом (#657, решение 2б).
|
||||
*
|
||||
* До #657 каждая задача пересобирала и коммитила бандл (`dist/**`,
|
||||
* `custom_components/houseplan/frontend/**`, класс D): за неделю 23–25.09 —
|
||||
* 34 коммита из 199, 16 MiB из ≈ 40 MiB прироста истории, потому что
|
||||
* content-hashed чанки не дельтируются между версиями. Две задачи, собравшие
|
||||
* бандл параллельно, конфликтовали на нём по построению.
|
||||
*
|
||||
* Теперь закоммиченный бандл — снимок последнего кандидата беты или релиза:
|
||||
*
|
||||
* - коммит, который трогает пути бандла, обязан нести трейлер `Release:`;
|
||||
* любой другой отклоняется хуком `commit-msg` и проверкой истории в CI;
|
||||
* - сверка «собранный = закоммиченный» (`bundle-tree dist frontend`) судит
|
||||
* только коммит, который бандл меняет, и кандидат (`Release v… candidate`),
|
||||
* даже если бандл в нём забыли пересобрать; на остальных проверяется
|
||||
* целостность свежей сборки — закоммиченная копия законно отстаёт.
|
||||
* Приёмка эталонов тоже несёт `Release:`, но бандл не трогает и после
|
||||
* хотфиксов к кандидату сверяться с ним не может;
|
||||
* - стенд `dev.houseplan.tech` берёт бандл из артефакта Validate
|
||||
* (`scripts/dev-build.mjs`, ветка `dev-build`), а не из дерева.
|
||||
*
|
||||
* node scripts/bundle-policy.mjs --must-match [<ref>] # код 0 — сверять копии, 1 — нет
|
||||
* node scripts/bundle-policy.mjs --verify [<ref>] # сборка dist цела; копии равны, если --must-match
|
||||
* node scripts/bundle-policy.mjs --clean # вернуть бандл к закоммиченному (npm run bundle:clean)
|
||||
*
|
||||
* Само правило коммита исполняет `validate-commit-provenance.mjs` — в хуке
|
||||
* `commit-msg` по индексу и в CI по диапазону истории.
|
||||
*
|
||||
* История до правила не переписывается: коммиты, написанные раньше
|
||||
* `BUNDLE_RELEASE_ONLY_SINCE`, судятся по-старому — иначе ветка, начатая до
|
||||
* #657 и уже собравшая бандл, не прошла бы приведение к `dev`.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { compareBundleTrees, verifyBundleTree } from './bundle-tree.mjs';
|
||||
|
||||
export const BUNDLE_ROOTS = Object.freeze(['dist/', 'custom_components/houseplan/frontend/']);
|
||||
|
||||
/** С этого момента (дата автора коммита) правило судит и историю. */
|
||||
export const BUNDLE_RELEASE_ONLY_SINCE = '2026-09-27T00:00:00Z';
|
||||
|
||||
export const BUNDLE_RELEASE_ONLY_ERROR = 'bundle (dist/**, custom_components/houseplan/frontend/**) changes only in a '
|
||||
+ "commit with a 'Release: vX.Y.Z' trailer (#657): restore it with `npm run bundle:clean`";
|
||||
|
||||
export const isBundlePath = (path) => {
|
||||
const normalized = String(path || '').replaceAll('\\', '/');
|
||||
return BUNDLE_ROOTS.some((root) => normalized.startsWith(root));
|
||||
};
|
||||
|
||||
const TRAILER = /^([A-Za-z][A-Za-z0-9-]*):\s*(.*?)\s*$/;
|
||||
|
||||
/** Значения трейлера `Release:` в терминальном блоке сообщения. */
|
||||
export function releaseTrailers(message) {
|
||||
const lines = String(message || '')
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.split('\n')
|
||||
.filter((line) => !line.startsWith('#'));
|
||||
while (lines.length && !lines.at(-1).trim()) lines.pop();
|
||||
const values = [];
|
||||
for (let index = lines.length - 1; index >= 0; index--) {
|
||||
const match = lines[index].match(TRAILER);
|
||||
if (!match) break;
|
||||
if (match[1] === 'Release' && match[2]) values.unshift(match[2]);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export const isReleaseMessage = (message) => releaseTrailers(message).length > 0;
|
||||
|
||||
/**
|
||||
* Ошибки правила для одного коммита. `authorDate` передаётся только при
|
||||
* проверке истории: коммит раньше `since` правилом не судится.
|
||||
*/
|
||||
export function bundleCommitErrors(message, files = [], {
|
||||
authorDate = null, since = BUNDLE_RELEASE_ONLY_SINCE,
|
||||
} = {}) {
|
||||
if (!files.some(isBundlePath)) return [];
|
||||
if (authorDate && Date.parse(authorDate) < Date.parse(since)) return [];
|
||||
return isReleaseMessage(message) ? [] : [BUNDLE_RELEASE_ONLY_ERROR];
|
||||
}
|
||||
|
||||
/**
|
||||
* Подпись кандидата беты/релиза: `Release vX.Y.Z[-beta.N] candidate` или
|
||||
* `Prepare vX.Y.Z-beta.N` (обе формы есть в истории). Это ранний сигнал
|
||||
* Validate; окончательно свежесть бандла судит публикация —
|
||||
* `assertCommittedBundleFresh` в `release-prerelease.mjs`, потому что
|
||||
* вершиной беты может оказаться и хотфикс поверх кандидата.
|
||||
*/
|
||||
export const isCandidateSubject = (subject) => /^(Release|Prepare) v\d/.test(String(subject || ''));
|
||||
|
||||
/**
|
||||
* Закоммиченный бандл собран из этого же дерева: отпечаток исходников,
|
||||
* вшитый сборкой в манифест, равен отпечатку дерева публикуемого SHA.
|
||||
* С #657 это единственное, что не даёт выпустить бету со старым бандлом,
|
||||
* если после кандидата в `dev` ушёл коммит без пересборки.
|
||||
*/
|
||||
export function assertCommittedBundleFresh(manifest, expectedFingerprint) {
|
||||
const actual = manifest?.fingerprint;
|
||||
if (!actual || actual !== expectedFingerprint) {
|
||||
throw new Error(`Committed bundle is stale: manifest fingerprint ${String(actual).slice(0, 12)} `
|
||||
+ `≠ source ${String(expectedFingerprint).slice(0, 12)}; rebuild the candidate with \`npm run bundle:release\` (#657)`);
|
||||
}
|
||||
return actual;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обязан ли закоммиченный бандл этого коммита совпасть со свежей сборкой:
|
||||
* да, если коммит бандл меняет (значит, он релизный по правилу выше) или
|
||||
* это кандидат — у кандидата бандл обязан быть свежим, даже если его забыли.
|
||||
*/
|
||||
export function committedBundleMustMatch({ subject = '', files = [] } = {}) {
|
||||
return files.some(isBundlePath) || isCandidateSubject(subject);
|
||||
}
|
||||
|
||||
function git(args, cwd) {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
|
||||
}
|
||||
|
||||
/** Тема и пути коммита. В shallow-клоне без родителя diff-tree выдал бы всё дерево. */
|
||||
function commitShape(ref, cwd) {
|
||||
const parents = git(['rev-list', '--parents', '-n', '1', ref], cwd).split(/\s+/).slice(1);
|
||||
if (!parents.length && git(['rev-parse', '--is-shallow-repository'], cwd) === 'true') {
|
||||
throw new Error(`${ref}: shallow-клон без родителя — пути коммита не определить (нужен fetch-depth: 0)`);
|
||||
}
|
||||
const subject = git(['show', '-s', '--format=%s', ref], cwd);
|
||||
const files = git(['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', ref], cwd)
|
||||
.split('\n').filter(Boolean);
|
||||
return { subject, files };
|
||||
}
|
||||
|
||||
function main(argv, cwd = process.cwd()) {
|
||||
const at = argv.indexOf('--must-match');
|
||||
if (at >= 0) {
|
||||
const ref = argv[at + 1] && !argv[at + 1].startsWith('--') ? argv[at + 1] : 'HEAD';
|
||||
const must = committedBundleMustMatch(commitShape(ref, cwd));
|
||||
console.log(must
|
||||
? `${ref}: коммит меняет бандл или это кандидат — закоммиченные копии обязаны совпасть со сборкой`
|
||||
: `${ref}: бандл не меняется — закоммиченная копия законно отстаёт, судится только свежая сборка (#657)`);
|
||||
return must ? 0 : 1;
|
||||
}
|
||||
const verifyAt = argv.indexOf('--verify');
|
||||
if (verifyAt >= 0) {
|
||||
// Одна точка решения для Validate и gate:small: свежая сборка обязана
|
||||
// быть целой всегда, а совпадать с закоммиченной копией — только там,
|
||||
// где коммит бандл меняет или объявлен кандидатом.
|
||||
const ref = argv[verifyAt + 1] && !argv[verifyAt + 1].startsWith('--') ? argv[verifyAt + 1] : 'HEAD';
|
||||
if (committedBundleMustMatch(commitShape(ref, cwd))) {
|
||||
const manifest = compareBundleTrees(resolve(cwd, 'dist'), resolve(cwd, 'custom_components/houseplan/frontend'));
|
||||
console.log(`${ref}: релизный коммит — dist и custom_components/houseplan/frontend совпадают (${manifest.files.length} ассетов)`);
|
||||
} else {
|
||||
const manifest = verifyBundleTree(resolve(cwd, 'dist'));
|
||||
console.log(`${ref}: свежая сборка dist цела (${manifest.files.length} ассетов); закоммиченная копия не сверяется — бандл меняет только кандидат (#657)`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (argv.includes('--clean')) {
|
||||
// Сборка переписывает отслеживаемый dist/ (и custom_components при
|
||||
// --release): перед обычным коммитом это вернуть. Новые чанки с новыми
|
||||
// хешами — неотслеживаемые файлы, их убирает clean в тех же корнях.
|
||||
const roots = BUNDLE_ROOTS.map((root) => root.replace(/\/$/, ''));
|
||||
git(['checkout', '--', ...roots], cwd);
|
||||
git(['clean', '-fdq', '--', ...roots], cwd);
|
||||
console.log(`бандл возвращён к закоммиченному: ${roots.join(', ')} (#657)`);
|
||||
return 0;
|
||||
}
|
||||
console.error('usage: bundle-policy.mjs --must-match [<ref>] | --verify [<ref>] | --clean');
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
|
||||
try {
|
||||
process.exitCode = main(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
console.error(`bundle-policy: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
+14
-10
@@ -2,12 +2,17 @@
|
||||
/**
|
||||
* Разложить собранный бандл по местам, которым он нужен (#255).
|
||||
*
|
||||
* Копий две с половиной. `custom_components/houseplan/frontend` — та, что
|
||||
* ставит HACS, она в репозитории и обязана совпадать с `dist` побайтово.
|
||||
* `demo/srv/assets` — рабочая копия стенда: её читают браузерные смоки, golden
|
||||
* и съёмка скриншотов, но в репозитории её больше нет. Раньше «скопировать
|
||||
* туда» жило шестью разными `cp` в воркфлоу и трижды в документации; когда
|
||||
* копию забывали, смок врал согласованно (#236).
|
||||
* Копий две с половиной. `demo/srv/assets` — рабочая копия стенда: её читают
|
||||
* браузерные смоки, golden и съёмка скриншотов, в репозитории её нет (#255).
|
||||
* `custom_components/houseplan/frontend` — та, что ставит HACS; с #657 она
|
||||
* обновляется только для релизного кандидата (`--release`, `npm run
|
||||
* bundle:release`): в остальных коммитах закоммиченный бандл законно отстаёт
|
||||
* от исходников, и обычная задача его не трогает. Раньше «скопировать туда»
|
||||
* жило шестью разными `cp` в воркфлоу и трижды в документации; когда копию
|
||||
* забывали, смок врал согласованно (#236).
|
||||
*
|
||||
* node scripts/bundle-sync.mjs # dist → demo/srv/assets
|
||||
* node scripts/bundle-sync.mjs --release # и в custom_components/houseplan/frontend
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
@@ -22,10 +27,9 @@ import {
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const SOURCE_ROOT = resolve(ROOT, 'dist');
|
||||
const MANIFEST_NAME = 'houseplan-assets.json';
|
||||
const TARGETS = [
|
||||
'custom_components/houseplan/frontend',
|
||||
'demo/srv/assets',
|
||||
];
|
||||
const DEMO_TARGET = 'demo/srv/assets';
|
||||
const RELEASE_TARGET = 'custom_components/houseplan/frontend';
|
||||
const TARGETS = process.argv.includes('--release') ? [RELEASE_TARGET, DEMO_TARGET] : [DEMO_TARGET];
|
||||
|
||||
const manifestPath = resolve(SOURCE_ROOT, MANIFEST_NAME);
|
||||
if (!existsSync(manifestPath)) {
|
||||
|
||||
@@ -62,7 +62,11 @@ export const JOB_RULES = Object.freeze({
|
||||
* чтобы контрактный тест видел ВСЕ job в обе стороны (#622 AC1): новая job
|
||||
* или переименование любой — решение, а не тихое расхождение.
|
||||
*/
|
||||
export const UNCONSUMED_JOBS = Object.freeze({ proof: 'Доказательство выполненных проверок' });
|
||||
export const UNCONSUMED_JOBS = Object.freeze({
|
||||
proof: 'Доказательство выполненных проверок',
|
||||
// #657: публикация бандла для стенда — не проверка кода и не вход proof.
|
||||
dev_build: 'Бандл головы dev для стенда',
|
||||
});
|
||||
|
||||
/** Общий префикс имён mutant-jobs — единственный источник для validate-gate. */
|
||||
export const MUTANT_JOB_PREFIX = JOB_RULES.mutants[0].name;
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Бандл головы `dev` для стенда разработки — из артефакта Validate (#657).
|
||||
*
|
||||
* С #657 закоммиченный бандл меняется только релизным кандидатом, поэтому
|
||||
* дерево `dev` между бетами несёт бандл последней беты. Стенд
|
||||
* `dev.houseplan.tech` должен показывать голову `dev`, и его источник —
|
||||
* собранный Validate `dist/` того же SHA (артефакт `card-bundle`), а не
|
||||
* дерево.
|
||||
*
|
||||
* Скрипт публикует этот `dist/` в служебную ветку `dev-build` одним
|
||||
* коммитом без родителей: `custom_components/houseplan/frontend/**` плюс
|
||||
* `DEV-BUILD.json` с SHA источника. Ветка перезаписывается каждый раз —
|
||||
* истории в ней нет, и в историю `dev` бандл больше не попадает. HACS её
|
||||
* не видит: он ставит релизы (`zip_release`), не ветки.
|
||||
*
|
||||
* Хост стенда забирает её `demo/stand/update-dev-bundle.sh`.
|
||||
*
|
||||
* node scripts/dev-build.mjs --sha <sha> [--dist dist] [--remote origin]
|
||||
* [--branch dev-build] [--expect-ref refs/heads/dev] [--dry-run]
|
||||
*
|
||||
* Публикуется только голова: если `--expect-ref` на удалённом уже ушёл
|
||||
* вперёд, прогон ничего не пушит — следующий Validate опубликует свежее.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { cpSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { verifyBundleTree } from './bundle-tree.mjs';
|
||||
|
||||
export const DEV_BUILD_BRANCH = 'dev-build';
|
||||
export const DEV_BUILD_MARKER = 'DEV-BUILD.json';
|
||||
export const DEV_BUILD_TARGET = 'custom_components/houseplan/frontend';
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const value = (name, fallback = null) => {
|
||||
const eq = argv.find((arg) => arg.startsWith(`--${name}=`));
|
||||
if (eq) return eq.slice(name.length + 3);
|
||||
const at = argv.indexOf(`--${name}`);
|
||||
return at >= 0 && argv[at + 1] && !argv[at + 1].startsWith('--') ? argv[at + 1] : fallback;
|
||||
};
|
||||
return {
|
||||
sha: value('sha'),
|
||||
dist: value('dist', 'dist'),
|
||||
remote: value('remote', 'origin'),
|
||||
branch: value('branch', DEV_BUILD_BRANCH),
|
||||
expectRef: value('expect-ref', 'refs/heads/dev'),
|
||||
dryRun: argv.includes('--dry-run'),
|
||||
};
|
||||
}
|
||||
|
||||
function makeGit(cwd, extraEnv = {}) {
|
||||
return (args, { allowFailure = false } = {}) => {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: { ...process.env, ...extraEnv } });
|
||||
if (r.error) throw r.error;
|
||||
if (r.status !== 0 && !allowFailure) {
|
||||
throw new Error(`git ${args.join(' ')} → ${(r.stderr || r.stdout || '').trim()}`);
|
||||
}
|
||||
return { ok: r.status === 0, out: (r.stdout || '').trim() };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Собрать коммит `dev-build` в объектах репозитория `cwd`, не трогая его
|
||||
* рабочее дерево и индекс: отдельный временный индекс и отдельное дерево.
|
||||
*/
|
||||
export function buildDevBuildCommit({ cwd, dist, sha, now = new Date() }) {
|
||||
const manifest = verifyBundleTree(resolve(cwd, dist));
|
||||
const stage = mkdtempSync(join(tmpdir(), 'hp-dev-build-'));
|
||||
try {
|
||||
cpSync(resolve(cwd, dist), join(stage, DEV_BUILD_TARGET), { recursive: true });
|
||||
const marker = {
|
||||
schema: 1,
|
||||
source: sha,
|
||||
fingerprint: manifest.fingerprint ?? null,
|
||||
files: manifest.files.length,
|
||||
builtAt: now.toISOString(),
|
||||
};
|
||||
writeFileSync(join(stage, DEV_BUILD_MARKER), `${JSON.stringify(marker, null, 2)}\n`);
|
||||
const index = join(stage, '.git-index');
|
||||
const git = makeGit(cwd, { GIT_INDEX_FILE: index });
|
||||
git(['read-tree', '--empty']);
|
||||
git(['--work-tree', stage, 'add', '-A', '--', DEV_BUILD_TARGET, DEV_BUILD_MARKER]);
|
||||
const tree = git(['write-tree']).out;
|
||||
const commit = git([
|
||||
'-c', 'user.name=github-actions[bot]',
|
||||
'-c', 'user.email=41898282+github-actions[bot]@users.noreply.github.com',
|
||||
'commit-tree', tree, '-m', `dev-build: ${sha}\n\nSource: ${sha}\nIssue: #657\nUser-Visible: no`,
|
||||
]).out;
|
||||
return { commit, tree, marker };
|
||||
} finally {
|
||||
rmSync(stage, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Опубликовать, только если источник — всё ещё голова `expectRef`. */
|
||||
export function publishDevBuild({ cwd = process.cwd(), sha, dist = 'dist', remote = 'origin',
|
||||
branch = DEV_BUILD_BRANCH, expectRef = 'refs/heads/dev', dryRun = false, log = console.log } = {}) {
|
||||
if (!/^[0-9a-f]{40}$/.test(String(sha || ''))) throw new Error(`--sha must be a full commit SHA, got ${JSON.stringify(sha)}`);
|
||||
const git = makeGit(cwd);
|
||||
const head = git(['ls-remote', remote, expectRef]).out.split(/\s+/)[0] || '';
|
||||
if (head && head !== sha) {
|
||||
log(`${expectRef} на ${remote} уже ${head.slice(0, 8)}, а сборка — ${sha.slice(0, 8)}: публикует следующий прогон`);
|
||||
return { published: false, reason: 'stale' };
|
||||
}
|
||||
const built = buildDevBuildCommit({ cwd, dist, sha });
|
||||
if (dryRun) {
|
||||
log(`--dry-run: ${branch} ← ${built.commit.slice(0, 8)} (${built.marker.files} ассетов из ${sha.slice(0, 8)})`);
|
||||
return { published: false, reason: 'dry-run', ...built };
|
||||
}
|
||||
// Ветка без истории: каждый прогон заменяет её целиком (один коммит без родителя).
|
||||
git(['push', '--force', remote, `${built.commit}:refs/heads/${branch}`]);
|
||||
log(`${branch} ← ${built.commit.slice(0, 8)}: бандл ${sha.slice(0, 8)} (${built.marker.files} ассетов)`);
|
||||
return { published: true, ...built };
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
|
||||
try {
|
||||
publishDevBuild(parseArgs(process.argv.slice(2)));
|
||||
} catch (error) {
|
||||
console.error(`dev-build: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,9 @@ export function parallelSteps(base) {
|
||||
export function postBuildSteps() {
|
||||
return [
|
||||
{ name: 'юниты (npm test)', cmd: npm, args: ['test'] },
|
||||
{ name: 'копии бандла совпадают (bundle-tree)', cmd: process.execPath, args: ['scripts/bundle-tree.mjs', 'dist', 'custom_components/houseplan/frontend'], hint: 'npm run bundle:sync' },
|
||||
// #657: сборка цела всегда; с закоммиченной копией сверяется только
|
||||
// кандидат — обычная задача бандл не коммитит.
|
||||
{ name: 'сборка бандла цела (bundle-policy --verify)', cmd: process.execPath, args: ['scripts/bundle-policy.mjs', '--verify', 'HEAD'], hint: 'кандидат: npm run bundle:release' },
|
||||
{ name: 'бюджет бандла', cmd: npm, args: ['run', 'bundle:budget'] },
|
||||
// #624: мёртвый код по noUnusedLocals и храповик связности монолита; после
|
||||
// сборки, потому что одно из чисел — размер dist/.
|
||||
|
||||
@@ -50,6 +50,9 @@ const inner = [
|
||||
'npm ci --no-audit --no-fund',
|
||||
'npm run bundle:sync',
|
||||
`npm run golden:${mode}`,
|
||||
// Сборка переписала отслеживаемый dist/ хозяйского дерева: с #657 его
|
||||
// меняет только кандидат, поэтому после съёмки он возвращается.
|
||||
'npm run bundle:clean',
|
||||
].join(' && ');
|
||||
|
||||
const args = [
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { reportCaptureProvenance } from './capture-environment.mjs';
|
||||
import { sourceFingerprint } from './source-fingerprint.mjs';
|
||||
import { pinsFromSources } from './toolchain-pins.mjs';
|
||||
import { isBundlePath } from './bundle-policy.mjs';
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
export const WSL_ATTESTATION_FILE = 'wsl-attestation.json';
|
||||
@@ -59,11 +60,23 @@ const normalizeRepository = (remote) => {
|
||||
return text.match(/github\.com[/:]([^/]+\/[^/]+)$/i)?.[1] || text;
|
||||
};
|
||||
|
||||
export function repositorySnapshot(root = ROOT, run = command) {
|
||||
/**
|
||||
* Строки porcelain без путей бандла. С #657 закоммиченный бандл законно
|
||||
* отстаёт от исходников, и `npm run bundle:sync` перед съёмкой переписывает
|
||||
* отслеживаемый `dist/`: это не правка источника, а сборка того же дерева.
|
||||
*/
|
||||
export const withoutBundlePaths = (status) => String(status || '').split('\n')
|
||||
// `command` обрезает вывод, и у первой строки пропадает ведущий пробел
|
||||
// статуса — путь берётся после кода статуса, а не фиксированным срезом.
|
||||
.filter((line) => line && !isBundlePath(line.replace(/^\s*\S{1,2}\s+/, '').replace(/^"|"$/g, '').split(' -> ').at(-1)))
|
||||
.join('\n');
|
||||
|
||||
export function repositorySnapshot(root = ROOT, run = command, { ignoreBundle = false } = {}) {
|
||||
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 rawStatus = run(root, 'git', ['status', '--porcelain=v1', '--untracked-files=all']);
|
||||
const status = ignoreBundle ? withoutBundlePaths(rawStatus) : rawStatus;
|
||||
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] || '';
|
||||
@@ -330,11 +343,14 @@ async function main() {
|
||||
rmSync(artifactRoot, { recursive: true, force: true });
|
||||
mkdirSync(artifactRoot, { recursive: true });
|
||||
runNpm(['run', 'bundle:sync']);
|
||||
const built = repositorySnapshot(ROOT);
|
||||
const built = repositorySnapshot(ROOT, command, { ignoreBundle: true });
|
||||
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']);
|
||||
// Стенд уже прочитал свою копию; отслеживаемый бандл возвращается к
|
||||
// закоммиченному, чтобы приёмка увидела чистое дерево (#657).
|
||||
runNpm(['run', 'bundle:clean']);
|
||||
const after = repositorySnapshot(ROOT);
|
||||
if (repositoryRefusal(after) || !matchingSource({ source: before }, after)) {
|
||||
throw new Error('repository changed while the WSL golden artifact was captured');
|
||||
|
||||
@@ -9460,6 +9460,73 @@ const MUTANT_DEFINITIONS = [
|
||||
}],
|
||||
},
|
||||
// #635: индекс ревью — база знаний; молчаливо неполный индекс хуже отсутствующего.
|
||||
// #657: бандл меняет только кандидат, индекс ревью — только коммиты в dev.
|
||||
{
|
||||
id: "bundle-policy-rule-off",
|
||||
guard: "node --test --test-name-pattern=\"#657 \u043f\u0440\u0430\u0432\u0438\u043b\u043e \u043a\u043e\u043c\u043c\u0438\u0442\u0430\" test/bundle-policy.test.mjs",
|
||||
because: "#657: a non-release commit must not carry the bundle \u2014 otherwise every task rebuilds and commits it again",
|
||||
patches: [{ file: "scripts/bundle-policy.mjs", find: " if (!files.some(isBundlePath)) return [];", replace: " if (files.length >= 0) return [];" }],
|
||||
},
|
||||
{
|
||||
id: "bundle-policy-cutoff-inverted",
|
||||
guard: "node --test --test-name-pattern=\"#657 \u0438\u0441\u0442\u043e\u0440\u0438\u044f \u0434\u043e \u043f\u0440\u0430\u0432\u0438\u043b\u0430\" test/bundle-policy.test.mjs",
|
||||
because: "#657: history before the rule is grandfathered, after it is judged \u2014 an inverted cutoff blocks old branches and frees new ones",
|
||||
patches: [{ file: "scripts/bundle-policy.mjs", find: " if (authorDate && Date.parse(authorDate) < Date.parse(since)) return [];", replace: " if (authorDate && Date.parse(authorDate) > Date.parse(since)) return [];" }],
|
||||
},
|
||||
{
|
||||
id: "bundle-policy-always-compares",
|
||||
guard: "node --test --test-name-pattern=\"#657 CLI --verify\" test/bundle-policy.test.mjs",
|
||||
because: "#657: comparing the committed copy on an ordinary commit would redden every task that no longer commits the bundle",
|
||||
patches: [{ file: "scripts/bundle-policy.mjs", find: " return files.some(isBundlePath) || isCandidateSubject(subject);", replace: " return files.length >= 0 || isCandidateSubject(subject);" }],
|
||||
},
|
||||
{
|
||||
id: "bundle-policy-candidate-not-compared",
|
||||
guard: "node --test --test-name-pattern=\"#657 \u0441\u0432\u0435\u0440\u043a\u0430 \u043a\u043e\u043f\u0438\u0439\" test/bundle-policy.test.mjs",
|
||||
because: "#657: a candidate whose bundle was forgotten must still be compared, or a stale bundle ships",
|
||||
patches: [{ file: "scripts/bundle-policy.mjs", find: " return files.some(isBundlePath) || isCandidateSubject(subject);", replace: " return files.some(isBundlePath);" }],
|
||||
},
|
||||
{
|
||||
id: "bundle-policy-fresh-check-off",
|
||||
guard: "node --test --test-name-pattern=\"#657 \u043f\u0443\u0431\u043b\u0438\u043a\u0430\u0446\u0438\u044f \u0431\u0435\u0442\u044b\" test/bundle-policy.test.mjs",
|
||||
because: "#657: a hotfix on top of a beta candidate must not publish the candidate's stale bundle",
|
||||
patches: [{ file: "scripts/bundle-policy.mjs", find: " if (!actual || actual !== expectedFingerprint) {", replace: " if (!actual) {" }],
|
||||
},
|
||||
{
|
||||
id: "release-prerelease-skips-fresh-bundle",
|
||||
guard: "node --test --test-name-pattern=\"#657 \u043f\u0443\u0431\u043b\u0438\u043a\u0430\u0446\u0438\u044f \u0431\u0435\u0442\u044b\" test/bundle-policy.test.mjs",
|
||||
because: "#657: the freshness check has to run in the publishing orchestrator, not only in a unit",
|
||||
patches: [{ file: "scripts/release-prerelease.mjs", find: " assertCommittedBundleFresh(manifest, sourceFingerprint(root));\n", replace: "" }],
|
||||
},
|
||||
{
|
||||
id: "provenance-skips-bundle-rule",
|
||||
guard: "node --test --test-name-pattern=\"#657 \u043f\u0440\u0430\u0432\u0438\u043b\u043e \u0438\u0441\u043f\u043e\u043b\u043d\u044f\u0435\u0442 validate-commit-provenance\" test/bundle-policy.test.mjs",
|
||||
because: "#657: the commit-msg hook and the CI history check are where the bundle rule actually bites",
|
||||
patches: [{ file: "scripts/validate-commit-provenance.mjs", find: " errors.push(...bundleCommitErrors(message, normalizedFiles, { authorDate }));", replace: " void bundleCommitErrors;" }],
|
||||
},
|
||||
{
|
||||
id: "bundle-sync-release-by-default",
|
||||
guard: "node --test --test-name-pattern=\"#486/#657 bundle sync\" test/bundle-sync.test.mjs",
|
||||
because: "#657: an ordinary sync must not touch the committed HACS copy",
|
||||
patches: [{ file: "scripts/bundle-sync.mjs", find: "const TARGETS = process.argv.includes('--release') ? [RELEASE_TARGET, DEMO_TARGET] : [DEMO_TARGET];", replace: "const TARGETS = [RELEASE_TARGET, DEMO_TARGET];" }],
|
||||
},
|
||||
{
|
||||
id: "dev-build-publishes-stale-head",
|
||||
guard: "node --test --test-name-pattern=\"#657 dev-build: dev \u0443\u0448\u0451\u043b \u0432\u043f\u0435\u0440\u0451\u0434\" test/dev-build.test.mjs",
|
||||
because: "#657: an older run finishing late must not overwrite the stand bundle of a newer dev head",
|
||||
patches: [{ file: "scripts/dev-build.mjs", find: " if (head && head !== sha) {", replace: " if (head && head === '') {" }],
|
||||
},
|
||||
{
|
||||
id: "dev-build-keeps-history",
|
||||
guard: "node --test --test-name-pattern=\"#657 dev-build: \u043e\u0434\u043d\u0430 \u0432\u0435\u0442\u043a\u0430 \u0431\u0435\u0437 \u0438\u0441\u0442\u043e\u0440\u0438\u0438\" test/dev-build.test.mjs",
|
||||
because: "#657: dev-build is replaced, not appended \u2014 a growing branch would bring the bundle history back",
|
||||
patches: [{ file: "scripts/dev-build.mjs", find: " 'commit-tree', tree, '-m',", replace: " 'commit-tree', tree, '-p', git(['rev-parse', 'HEAD']).out, '-m'," }],
|
||||
},
|
||||
{
|
||||
id: "process-index-on-task-branch",
|
||||
guard: "node --test --test-name-pattern=\"#635/#657 \\\\(1\u0431\\\\)\" test/reviews-index.test.mjs",
|
||||
because: "#657 (1b): the review index is rebuilt only by commits that go to dev; in a task branch it conflicts by construction",
|
||||
patches: [{ file: ".github/workflows/_process.yml", find: " if [ -f \"$doc\" ] && [ \"$target\" = \"dev\" ]; then", replace: " if [ -f \"$doc\" ] && [ -n \"$target\" ]; then" }],
|
||||
},
|
||||
{
|
||||
id: 'reviews-index-skips-self-check',
|
||||
guard: 'node --test --test-name-pattern="#635 индекс покрывает" test/reviews-index.test.mjs',
|
||||
|
||||
@@ -38,9 +38,12 @@
|
||||
* исполняемым диффом, которая не HEAD, отклоняется — иначе зелёный вердикт был
|
||||
* бы вынесен чужому дереву; обход назван в сообщении.
|
||||
*
|
||||
* Бандл не собирается: `bundle-sync.mjs` раскладывает закоммиченный `dist`, а
|
||||
* Бандл не собирается: `bundle-sync.mjs` раскладывает `dist` рабочего дерева, а
|
||||
* свежесть проверяет сам продукт — `assertFreshDemoBundle` внутри каждого смока
|
||||
* сверяет вшитый отпечаток с исходниками дерева и скажет, если нужна пересборка.
|
||||
* С #657 закоммиченный `dist` законно отстаёт до кандидата беты: перед push
|
||||
* ветки, трогающей исходники, соберите `npm run build` (в коммит сборка не
|
||||
* идёт — `npm run bundle:clean`, если она попала в индекс).
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
|
||||
+15
-32
@@ -2,12 +2,11 @@
|
||||
// Ребейз ветки задачи на origin/dev без ручных конфликтов в бандле (#479).
|
||||
//
|
||||
// Бандл лежит в репозитории (класс D: dist/**, custom_components/houseplan/
|
||||
// frontend/**), поэтому две задачи, собравшие его параллельно, конфликтуют на
|
||||
// нём всегда — 1.16 МБ минифицированного текста плюс переименованные
|
||||
// content-hashed чанки. Руками это не решается, решается пересборкой. Скрипт
|
||||
// делает ровно это: при конфликте ТОЛЬКО в сгенерированных путях берёт версию
|
||||
// dev, доводит ребейз до конца, пересобирает бандл (`npm run bundle:sync`) и,
|
||||
// если он отличается, амендит последний коммит ветки. Индекс ревью
|
||||
// frontend/**). С #657 его меняет только релизный кандидат, а ветка задачи не
|
||||
// несёт его вовсе; конфликт на нём остаётся возможен лишь у ветки, начатой до
|
||||
// правила. Такой конфликт решается версией dev — без пересборки и без
|
||||
// амендинга: собранный бандл в ветке был бы коммитом, который правило
|
||||
// отклонит (`scripts/bundle-policy.mjs`). Индекс ревью
|
||||
// `docs/reviews/INDEX.md` (#643) — тоже генерируемый: при конфликте он
|
||||
// пересобирается по каталогу в дереве остановки (общий помощник
|
||||
// `rebase-generated.mjs`, тот же, что у конвейера). Конфликт в любом другом
|
||||
@@ -19,13 +18,13 @@
|
||||
// Дерево должно быть чистым. Ветка `dev` сама себя не ребейзит.
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, rmSync } from 'node:fs';
|
||||
import { rmSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { portableCommand } from './spawn-portable.mjs';
|
||||
import { REVIEWS_INDEX_PATH, rebaseRegenerating } from './rebase-generated.mjs';
|
||||
import { BUNDLE_ROOTS } from './bundle-policy.mjs';
|
||||
|
||||
export const GENERATED_ROOTS = ['dist/', 'custom_components/houseplan/frontend/'];
|
||||
export const GENERATED_ROOTS = BUNDLE_ROOTS;
|
||||
export const isGenerated = (path) => GENERATED_ROOTS.some((root) => path.startsWith(root));
|
||||
/** Генерируемые пути, которые решаются пересборкой в момент остановки, а не версией dev (#643). */
|
||||
export const REGENERATED_PATHS = [REVIEWS_INDEX_PATH];
|
||||
@@ -73,7 +72,7 @@ export function resolveGeneratedConflict(git, path) {
|
||||
|
||||
export function rebaseOnDev({
|
||||
cwd = process.cwd(), upstream = 'origin/dev', dryRun = false,
|
||||
syncCommand = ['npm', 'run', 'bundle:sync'], log = console.log, fetch = true,
|
||||
log = console.log, fetch = true,
|
||||
} = {}) {
|
||||
const git = Object.assign(makeGit(cwd), { cwd });
|
||||
const dirty = git(['status', '--porcelain']).stdout;
|
||||
@@ -89,7 +88,7 @@ export function rebaseOnDev({
|
||||
const ahead = Number(git(['rev-list', '--count', `${upstream}..HEAD`]).stdout);
|
||||
const behind = Number(git(['rev-list', '--count', `HEAD..${upstream}`]).stdout);
|
||||
log(`ветка ${branch}: впереди ${upstream} на ${ahead}, позади на ${behind}`);
|
||||
if (behind === 0) { log('ребейз не нужен'); return { branch, rebased: false, resolved: [], rebuilt: false }; }
|
||||
if (behind === 0) { log('ребейз не нужен'); return { branch, rebased: false, resolved: [] }; }
|
||||
|
||||
// Предсказание конфликтов по сгенерированным путям: файлы, которые менялись
|
||||
// по обе стороны от merge-base. Точный список даёт только сам ребейз.
|
||||
@@ -97,10 +96,10 @@ export function rebaseOnDev({
|
||||
const theirs = git(['diff', '--name-only', base, upstream]).stdout.split('\n').filter(Boolean);
|
||||
const both = theirs.filter((path) => ours.has(path));
|
||||
const predicted = splitConflicts(both);
|
||||
if (predicted.generated.length) log(`бандл менялся с обеих сторон: ${predicted.generated.length} файл(ов) — решится пересборкой`);
|
||||
if (predicted.generated.length) log(`бандл менялся с обеих сторон: ${predicted.generated.length} файл(ов) — возьмётся версия dev (#657)`);
|
||||
if (predicted.regenerated.length) log(`индекс ревью менялся с обеих сторон — решится пересборкой по каталогу: ${predicted.regenerated.join(', ')}`);
|
||||
if (predicted.manual.length) log(`менялись с обеих сторон и НЕ сгенерированы (возможен ручной конфликт): ${predicted.manual.join(', ')}`);
|
||||
if (dryRun) { log('--dry-run: дерево не тронуто'); return { branch, rebased: false, resolved: [], rebuilt: false, predicted }; }
|
||||
if (dryRun) { log('--dry-run: дерево не тронуто'); return { branch, rebased: false, resolved: [], predicted }; }
|
||||
|
||||
// Цикл остановок — общий с конвейером (#643): индекс ревью пересобирается
|
||||
// помощником, бандл — версией dev здесь, всё прочее — отказ с abort.
|
||||
@@ -116,25 +115,9 @@ export function rebaseOnDev({
|
||||
}
|
||||
const { resolved } = outcome;
|
||||
log(`ребейз завершён; сгенерированных конфликтов решено: ${resolved.length}`);
|
||||
|
||||
// Пересборка: версия dev в бандле — не версия этой ветки. Собираем и, если
|
||||
// бандл отличается, амендим последний коммит ветки.
|
||||
const [cmd, ...args] = syncCommand;
|
||||
// Оболочка только для npm.cmd на Windows (#496): `node -e "…"` из теста и
|
||||
// любая команда с кавычками через shell разваливаются.
|
||||
const portable = portableCommand(cmd);
|
||||
const sync = spawnSync(portable.cmd, args, { cwd, stdio: 'inherit', shell: portable.shell });
|
||||
if (sync.status !== 0) throw new Error(`${syncCommand.join(' ')} завершился с кодом ${sync.status}; ребейз сделан, бандл не закоммичен`);
|
||||
git(['add', '-A', '--', ...GENERATED_ROOTS.filter((root) => existsSync(resolve(cwd, root)))]);
|
||||
const staged = git(['diff', '--cached', '--name-only']).stdout;
|
||||
const rebuilt = staged.length > 0;
|
||||
if (rebuilt) {
|
||||
git(['commit', '--amend', '--no-edit', '--quiet']);
|
||||
log(`бандл пересобран и добавлен в последний коммит (${staged.split('\n').length} файл(ов))`);
|
||||
} else {
|
||||
log('бандл после пересборки совпал с dev — амендить нечего');
|
||||
}
|
||||
return { branch, rebased: true, resolved, rebuilt };
|
||||
// #657: бандл в ветке задачи не пересобирается и не коммитится — его
|
||||
// меняет только кандидат (npm run bundle:release).
|
||||
return { branch, rebased: true, resolved };
|
||||
}
|
||||
|
||||
const invokedDirectly = process.argv[1]
|
||||
|
||||
@@ -14,6 +14,8 @@ import { stdin, stdout } from 'node:process';
|
||||
import { assertReleaseContract } from './release-contract.mjs';
|
||||
import { candidateExpectations, classifyValidateProofs } from './release-gate.mjs';
|
||||
import { assertBundleManifest } from './bundle-tree.mjs';
|
||||
import { assertCommittedBundleFresh } from './bundle-policy.mjs';
|
||||
import { sourceFingerprint } from './source-fingerprint.mjs';
|
||||
import { SUMS_FILE, compareSums, formatSums, parseSums, sumsOfDirectory } from './release-assets.mjs';
|
||||
import {
|
||||
MEMBERSHIP_FILE, buildReleaseMembership, readCandidateHistory,
|
||||
@@ -294,6 +296,9 @@ if (invokedDirectly) {
|
||||
throw new Error(`Committed bundle asset hash mismatch: ${file.path}`);
|
||||
}
|
||||
}
|
||||
// Рабочее дерево чисто и стоит на `sha` (проверено выше), поэтому его
|
||||
// отпечаток — отпечаток публикуемого коммита (#657).
|
||||
assertCommittedBundleFresh(manifest, sourceFingerprint(root));
|
||||
const entry = manifest.files.find((file) => file.path === manifest.entry);
|
||||
return { manifest, entrySha256: entry.sha256 };
|
||||
};
|
||||
|
||||
@@ -158,7 +158,7 @@ step_bundle() {
|
||||
[ -x "$WT/node_modules/.bin/tsc" ] || die "нет node_modules — сначала шаг deps"
|
||||
(cd "$WT" && npm run --silent bundle:sync >/tmp/hp-bootstrap-bundle.log 2>&1) \
|
||||
|| { tail -30 /tmp/hp-bootstrap-bundle.log >&2; die "npm run bundle:sync не удался (лог: /tmp/hp-bootstrap-bundle.log)"; }
|
||||
say "bundle: npm run bundle:sync — ok (dist, custom_components/houseplan/frontend, demo/srv/assets)"
|
||||
say "bundle: npm run bundle:sync — ok (dist, demo/srv/assets; custom_components — только кандидат, #657)"
|
||||
}
|
||||
|
||||
step_check() {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { execFileSync } from 'node:child_process';
|
||||
import { basename } from 'node:path';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { bundleCommitErrors } from './bundle-policy.mjs';
|
||||
|
||||
const TRAILER = /^([A-Za-z][A-Za-z0-9-]*):\s*(.*?)\s*$/;
|
||||
export const ENFORCEMENT_BOUNDARY = '8e2973fa7a7cb1a80204ff95ecf3f2d7c36ed2ce';
|
||||
@@ -51,7 +52,7 @@ export function terminalTrailers(message) {
|
||||
return out;
|
||||
}
|
||||
|
||||
export function validateCommitMessage(message, changedFiles = [], { baselineIndex = undefined } = {}) {
|
||||
export function validateCommitMessage(message, changedFiles = [], { baselineIndex = undefined, authorDate = null } = {}) {
|
||||
const trailers = terminalTrailers(message);
|
||||
const errors = [];
|
||||
const issues = trailers.get('Issue') || [];
|
||||
@@ -70,6 +71,9 @@ export function validateCommitMessage(message, changedFiles = [], { baselineInde
|
||||
}
|
||||
}
|
||||
}
|
||||
// #657: бандл меняет только релизный кандидат. В хуке даты нет — судится
|
||||
// всегда; в истории коммиты раньше BUNDLE_RELEASE_ONLY_SINCE не судятся.
|
||||
errors.push(...bundleCommitErrors(message, normalizedFiles, { authorDate }));
|
||||
const changesGolden = changedFiles.some((file) =>
|
||||
/^demo\/golden\/baselines\/.*\.(png|json)$/.test(file.replaceAll('\\', '/')));
|
||||
if (changesGolden) {
|
||||
@@ -197,13 +201,14 @@ function main(argv) {
|
||||
const parentCount = Number(git(['rev-list', '--parents', '-n', '1', commit]).split(/\s+/).length) - 1;
|
||||
if (parentCount > 1) continue;
|
||||
const message = git(['show', '-s', '--format=%B', commit]);
|
||||
const authorDate = git(['show', '-s', '--format=%aI', commit]);
|
||||
const files = git(['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', commit])
|
||||
.split('\n').filter(Boolean);
|
||||
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 });
|
||||
const errors = validateHistoricalCommit(commit, message, files, { baselineIndex, authorDate });
|
||||
if (errors.length) throw new Error(`${commit}:\n- ${errors.join('\n- ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user