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:
Claude
2026-09-26 07:28:23 +00:00
committed by claude[bot]
parent 4b8a82c08a
commit 88c4e4e9ae
34 changed files with 1021 additions and 136 deletions
+180
View File
@@ -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
View File
@@ -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)) {
+5 -1
View File
@@ -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;
+125
View File
@@ -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;
}
}
+3 -1
View File
@@ -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/.
+3
View File
@@ -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 = [
+19 -3
View File
@@ -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');
+67
View File
@@ -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',
+4 -1
View File
@@ -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
View File
@@ -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]
+5
View File
@@ -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 };
};
+1 -1
View File
@@ -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() {
+7 -2
View File
@@ -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- ')}`);
}
}