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
+156
View File
@@ -0,0 +1,156 @@
// #657 (решение 2б): бандл меняет только релизный кандидат; сверка копий —
// только там, где бандл меняется или объявлен кандидат; стенд dev — из артефакта.
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import {
BUNDLE_RELEASE_ONLY_ERROR, BUNDLE_RELEASE_ONLY_SINCE, BUNDLE_ROOTS, bundleCommitErrors,
assertCommittedBundleFresh, committedBundleMustMatch, isBundlePath, isCandidateSubject, releaseTrailers,
} from '../scripts/bundle-policy.mjs';
import { validateCommitMessage } from '../scripts/validate-commit-provenance.mjs';
import { GENERATED_ROOTS } from '../scripts/rebase-on-dev.mjs';
const POLICY = fileURLToPath(new URL('../scripts/bundle-policy.mjs', import.meta.url));
const TREE = fileURLToPath(new URL('../scripts/bundle-tree.mjs', import.meta.url));
const WORK = 'feat: x\n\nIssue: #657\nUser-Visible: no\n';
const RELEASE = 'Release v1.79.0-beta.1 candidate\n\nIssue: #657\nUser-Visible: yes\nRelease: v1.79.0-beta.1\n';
test('#657 пути бандла — один источник для политики и ребейза', () => {
assert.deepEqual([...BUNDLE_ROOTS], ['dist/', 'custom_components/houseplan/frontend/']);
assert.equal(GENERATED_ROOTS, BUNDLE_ROOTS, 'rebase-on-dev читает корни отсюда');
assert.equal(isBundlePath('dist/houseplan-card.js'), true);
assert.equal(isBundlePath('custom_components\\houseplan\\frontend\\houseplan-assets.json'), true);
assert.equal(isBundlePath('custom_components/houseplan/const.py'), false);
assert.equal(isBundlePath('demo/srv/assets/houseplan-card.js'), false, 'копия стенда не коммитится вовсе (#255)');
assert.equal(isBundlePath('distribution.md'), false);
});
test('#657 правило коммита: бандл — только с трейлером Release', () => {
assert.deepEqual(bundleCommitErrors(WORK, ['src/a.ts', 'dist/houseplan-card.js']), [BUNDLE_RELEASE_ONLY_ERROR]);
assert.deepEqual(bundleCommitErrors(WORK, ['custom_components/houseplan/frontend/houseplan-assets.json']), [BUNDLE_RELEASE_ONLY_ERROR]);
assert.deepEqual(bundleCommitErrors(RELEASE, ['dist/houseplan-card.js']), []);
assert.deepEqual(bundleCommitErrors(WORK, ['src/a.ts', 'docs/x.md']), [], 'без путей бандла правило молчит');
assert.deepEqual(bundleCommitErrors('feat\n\nRelease: v1\nIssue: #1\n', ['dist/a.js']), [],
'трейлер в терминальном блоке, в любом месте блока');
assert.deepEqual(bundleCommitErrors('feat\n\nRelease: v1 mentioned in prose\n\nIssue: #1\n', ['dist/a.js']),
[BUNDLE_RELEASE_ONLY_ERROR], 'строка вне терминального блока трейлером не считается');
assert.deepEqual(releaseTrailers(`${RELEASE}# Please enter the commit message\n`), ['v1.79.0-beta.1']);
});
test('#657 история до правила не переписывается: дата автора раньше порога не судится', () => {
const before = new Date(Date.parse(BUNDLE_RELEASE_ONLY_SINCE) - 1000).toISOString();
const after = new Date(Date.parse(BUNDLE_RELEASE_ONLY_SINCE) + 1000).toISOString();
assert.deepEqual(bundleCommitErrors(WORK, ['dist/a.js'], { authorDate: before }), []);
assert.deepEqual(bundleCommitErrors(WORK, ['dist/a.js'], { authorDate: after }), [BUNDLE_RELEASE_ONLY_ERROR]);
assert.deepEqual(bundleCommitErrors(WORK, ['dist/a.js']), [BUNDLE_RELEASE_ONLY_ERROR], 'хук: даты нет — судится всегда');
});
test('#657 правило исполняет validate-commit-provenance — и хук, и история', () => {
assert.ok(validateCommitMessage(WORK, ['dist/houseplan-card.js']).includes(BUNDLE_RELEASE_ONLY_ERROR));
assert.ok(!validateCommitMessage(RELEASE, ['dist/houseplan-card.js', 'docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md'])
.includes(BUNDLE_RELEASE_ONLY_ERROR));
assert.ok(!validateCommitMessage(WORK, ['dist/houseplan-card.js'], { authorDate: '2026-09-20T10:00:00+03:00' })
.includes(BUNDLE_RELEASE_ONLY_ERROR), 'коммит до порога проходит по-старому');
});
test('#657 сверка копий: коммит меняет бандл или объявлен кандидатом', () => {
assert.equal(committedBundleMustMatch({ subject: 'feat: x', files: ['src/a.ts'] }), false);
assert.equal(committedBundleMustMatch({ subject: 'chore(golden): эталоны', files: ['demo/golden/baselines/a.png'] }), false,
'приёмка эталонов несёт Release:, но бандл не трогает — после хотфиксов к кандидату сверяться не может');
assert.equal(committedBundleMustMatch({ subject: 'Release v1.79.0-beta.1 candidate', files: ['package.json'] }), true,
'кандидат, в котором бандл забыли пересобрать, всё равно сверяется');
assert.equal(committedBundleMustMatch({ subject: 'x', files: ['custom_components/houseplan/frontend/houseplan-card.js'] }), true);
assert.equal(isCandidateSubject('Release v1.78.0 candidate'), true);
assert.equal(isCandidateSubject('Prepare v1.77.0-beta.5'), true, 'форма из истории беты 1.77');
assert.equal(isCandidateSubject('docs: Release v1 notes'), false);
});
test('#657 публикация беты: закоммиченный бандл обязан быть собран из публикуемого дерева', () => {
assert.equal(assertCommittedBundleFresh({ fingerprint: 'a'.repeat(64) }, 'a'.repeat(64)), 'a'.repeat(64));
assert.throws(() => assertCommittedBundleFresh({ fingerprint: 'a'.repeat(64) }, 'b'.repeat(64)),
/Committed bundle is stale.*bundle:release/);
assert.throws(() => assertCommittedBundleFresh({}, 'b'.repeat(64)), /stale/);
// Проводка: проверку вызывает сам оркестратор публикации, а не только Validate.
const source = readFileSync(fileURLToPath(new URL('../scripts/release-prerelease.mjs', import.meta.url)), 'utf8');
assert.match(source, /assertCommittedBundleFresh\(manifest, sourceFingerprint\(root\)\)/);
});
// --- CLI на настоящем git-репозитории ---------------------------------------
const git = (cwd, ...args) => execFileSync('git', args, {
cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' },
}).trim();
const sha = (text) => execFileSync(process.execPath, ['-e',
"process.stdout.write(require('crypto').createHash('sha256').update(process.argv[1]).digest('hex'))", text], { encoding: 'utf8' });
function writeBundle(root, dir, card) {
mkdirSync(join(root, dir), { recursive: true });
const panel = 'panel';
writeFileSync(join(root, dir, 'houseplan-card.js'), card);
writeFileSync(join(root, dir, 'houseplan-panel.js'), panel);
const files = [
{ path: 'houseplan-card.js', sha256: sha(card), rawBytes: card.length, gzipBytes: 1, isEntry: true, imports: [], dynamicImports: [] },
{ path: 'houseplan-panel.js', sha256: sha(panel), rawBytes: panel.length, gzipBytes: 1, isEntry: true, imports: [], dynamicImports: [] },
];
writeFileSync(join(root, dir, 'houseplan-assets.json'), `${JSON.stringify({
schema: 1, fingerprint: 'f'.repeat(64), entry: 'houseplan-card.js', panelEntry: 'houseplan-panel.js',
initialViewFiles: ['houseplan-card.js'], initialViewGzipBytes: 1,
initialPanelFiles: ['houseplan-panel.js'], initialPanelGzipBytes: 1,
initialPanelOnlyFiles: ['houseplan-panel.js'], initialPanelOnlyGzipBytes: 1, files,
})}\n`);
}
function fixture() {
const root = mkdtempSync(join(tmpdir(), 'hp-bundle-policy-'));
mkdirSync(join(root, 'scripts'));
for (const [from, name] of [[POLICY, 'bundle-policy.mjs'], [TREE, 'bundle-tree.mjs']]) {
writeFileSync(join(root, 'scripts', name), readFileSync(from));
}
git(root, 'init', '-q', '-b', 'dev');
writeBundle(root, 'dist', 'card v1');
writeBundle(root, 'custom_components/houseplan/frontend', 'card v1');
git(root, 'add', '-A');
git(root, 'commit', '-q', '-m', RELEASE);
return root;
}
const cli = (root, ...args) => spawnSync(process.execPath, ['scripts/bundle-policy.mjs', ...args], { cwd: root, encoding: 'utf8' });
test('#657 CLI --verify: кандидат сверяет копии, обычный коммит — только свежую сборку', () => {
const root = fixture();
try {
assert.equal(cli(root, '--must-match').status, 0, 'кандидат');
assert.equal(cli(root, '--verify', 'HEAD').status, 0, 'копии кандидата равны');
writeFileSync(join(root, 'src.ts'), 'change');
git(root, 'add', 'src.ts');
git(root, 'commit', '-q', '-m', WORK);
writeBundle(root, 'dist', 'card v2');
assert.equal(cli(root, '--must-match').status, 1, 'обычный коммит сверять не обязан');
const work = cli(root, '--verify', 'HEAD');
assert.equal(work.status, 0, work.stderr);
assert.match(work.stdout, /закоммиченная копия не сверяется/);
git(root, 'commit', '-q', '--allow-empty', '-m', 'Release v1.79.0-beta.2 candidate\n\nIssue: #657\nUser-Visible: no\nRelease: v1.79.0-beta.2\n');
const stale = cli(root, '--verify', 'HEAD');
assert.notEqual(stale.status, 0, 'кандидат без пересобранного бандла — отказ');
assert.match(stale.stderr, /bundle asset differs|manifests differ/);
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('#657 CLI --clean возвращает бандл к закоммиченному и убирает новые чанки', () => {
const root = fixture();
try {
writeBundle(root, 'dist', 'card rebuilt');
mkdirSync(join(root, 'dist/houseplan-assets'), { recursive: true });
writeFileSync(join(root, 'dist/houseplan-assets/new-HASH.js'), 'chunk');
writeFileSync(join(root, 'notes.txt'), 'keep me');
const run = cli(root, '--clean');
assert.equal(run.status, 0, run.stderr);
assert.equal(readFileSync(join(root, 'dist/houseplan-card.js'), 'utf8'), 'card v1');
assert.equal(git(root, 'status', '--porcelain', '--', 'dist', 'custom_components'), '');
assert.equal(readFileSync(join(root, 'notes.txt'), 'utf8'), 'keep me', 'вне корней бандла ничего не трогается');
} finally { rmSync(root, { recursive: true, force: true }); }
});
+12 -2
View File
@@ -69,7 +69,7 @@ const writeLegacyTarget = (root, target) => {
}));
};
test('#486 bundle sync materializes both entries and removes the legacy inventory', () => {
test('#486/#657 bundle sync materializes both entries, the HACS copy only with --release', () => {
const root = mkdtempSync(join(tmpdir(), 'houseplan-bundle-sync-'));
try {
mkdirSync(join(root, 'scripts'), { recursive: true });
@@ -84,7 +84,17 @@ test('#486 bundle sync materializes both entries and removes the legacy inventor
'custom_components/houseplan/frontend', 'demo/srv/assets',
]) writeLegacyTarget(root, target);
const run = spawnSync(process.execPath, ['scripts/bundle-sync.mjs'], {
// #657: без --release копия HACS не трогается — бандл меняет только кандидат.
const demoOnly = spawnSync(process.execPath, ['scripts/bundle-sync.mjs'], {
cwd: root, encoding: 'utf8',
});
assert.equal(demoOnly.status, 0, `${demoOnly.stdout}\n${demoOnly.stderr}`);
assert.equal(readFileSync(join(root, 'demo/srv/assets/houseplan-card.js'), 'utf8'), 'card facade');
assert.equal(readFileSync(join(root, 'custom_components/houseplan/frontend/houseplan-card.js'), 'utf8'), 'old card',
'обычная раскладка не должна менять закоммиченный бандл (#657)');
assert.equal(existsSync(join(root, 'custom_components/houseplan/frontend/houseplan-legacy.js')), true);
const run = spawnSync(process.execPath, ['scripts/bundle-sync.mjs', '--release'], {
cwd: root, encoding: 'utf8',
});
assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
+51 -16
View File
@@ -1,8 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import { dirname, resolve } from 'node:path';
import { compareBundleTrees, readBundleManifest, verifyBundleTree } from '../scripts/bundle-tree.mjs';
@@ -17,16 +19,42 @@ import { compareBundleTrees, readBundleManifest, verifyBundleTree } from '../scr
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const COPIES = ['dist', 'custom_components/houseplan/frontend'];
test('манифест бандла не ссылается в никуда, обе копии целы и равны (#349)', () => {
for (const copy of COPIES) {
// verifyBundleTree отвечает сразу на три вопроса: файл существует, его
// sha256 совпадает с манифестом, путь не выходит за корень копии.
assert.doesNotThrow(() => verifyBundleTree(resolve(ROOT, copy)), `${copy}: дерево бандла битое`);
// #657: судится ЗАКОММИЧЕННЫЙ снимок, а не диск. С #657 бандл в дереве меняет
// только кандидат, и после обычного `npm run build` отслеживаемый `dist/` на
// диске законно новее `custom_components/houseplan/frontend` — сравнение диска
// красило бы любую сборку. Снимок HEAD обязан быть цел и равен себе в обеих
// копиях всегда: его ставит HACS и его публикует бета.
function committedCopies() {
const listed = spawnSync('git', ['-C', ROOT, 'ls-tree', '-r', '-z', '--name-only', 'HEAD', '--', ...COPIES], { encoding: 'utf8' });
if (listed.status !== 0) return null;
const root = mkdtempSync(resolve(tmpdir(), 'hp-committed-bundle-'));
for (const path of listed.stdout.split('\0').filter(Boolean)) {
const blob = spawnSync('git', ['-C', ROOT, 'show', `HEAD:${path}`], { maxBuffer: 64 * 1024 * 1024 });
assert.equal(blob.status, 0, `git show HEAD:${path}`);
mkdirSync(dirname(resolve(root, path)), { recursive: true });
writeFileSync(resolve(root, path), blob.stdout);
}
return root;
}
test('манифест бандла не ссылается в никуда, обе копии целы и равны (#349)', () => {
const committed = committedCopies();
// Без git (распакованный архив) — проверяется то, что лежит на диске, громко.
if (!committed) console.log('ПРОПУЩЕНО: git недоступен, судится диск вместо закоммиченного снимка');
const base = committed ?? ROOT;
try {
for (const copy of COPIES) {
// verifyBundleTree отвечает сразу на три вопроса: файл существует, его
// sha256 совпадает с манифестом, путь не выходит за корень копии.
assert.doesNotThrow(() => verifyBundleTree(resolve(base, copy)), `${copy}: дерево бандла битое`);
}
assert.doesNotThrow(
() => compareBundleTrees(resolve(base, COPIES[0]), resolve(base, COPIES[1])),
'копии бандла разошлись: HACS ставит вторую, а сверяется первая',
);
} finally {
if (committed) rmSync(committed, { recursive: true, force: true });
}
assert.doesNotThrow(
() => compareBundleTrees(resolve(ROOT, COPIES[0]), resolve(ROOT, COPIES[1])),
'копии бандла разошлись: HACS ставит вторую, а сверяется первая',
);
});
test('каждый файл манифеста отслеживается git, а не только лежит на диске (#349)', () => {
@@ -46,13 +74,20 @@ test('каждый файл манифеста отслеживается git,
const tracked = new Set(listed.stdout.split('\0').filter(Boolean));
assert.ok(tracked.size, 'git ls-files не вернул ни одного файла — проверьте вызов');
const missing = [];
for (const copy of COPIES) {
const manifest = readBundleManifest(resolve(ROOT, copy));
assert.ok(manifest.files.length, `${copy}: манифест без файлов`);
for (const file of manifest.files) {
const relative = `${copy}/${file.path}`;
if (!tracked.has(relative)) missing.push(relative);
// Манифест — закоммиченный (#657): на диске после сборки лежит новый, с
// хешами, которых в индексе законно нет.
const committed = committedCopies();
try {
for (const copy of COPIES) {
const manifest = readBundleManifest(resolve(committed ?? ROOT, copy));
assert.ok(manifest.files.length, `${copy}: манифест без файлов`);
for (const file of manifest.files) {
const relative = `${copy}/${file.path}`;
if (!tracked.has(relative)) missing.push(relative);
}
}
} finally {
if (committed) rmSync(committed, { recursive: true, force: true });
}
assert.deepEqual(missing, [],
'манифест ссылается на файлы, которых нет в индексе git: собрано, но не закоммичено');
+126
View File
@@ -0,0 +1,126 @@
// #657 (2б): стенд dev берёт бандл из артефакта Validate через ветку dev-build,
// а не из дерева dev, где бандл меняет только кандидат.
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';
import {
DEV_BUILD_MARKER, DEV_BUILD_TARGET, parseArgs, publishDevBuild,
} from '../scripts/dev-build.mjs';
const STAND = fileURLToPath(new URL('../demo/stand/update-dev-bundle.sh', import.meta.url));
const ENV = { GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' };
const git = (cwd, ...args) => execFileSync('git', args, {
cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, ...ENV },
}).trim();
const sha256 = (text) => createHash('sha256').update(text).digest('hex');
function writeDist(root, card) {
const dir = join(root, 'dist');
mkdirSync(join(dir, 'houseplan-assets'), { recursive: true });
const entries = [['houseplan-card.js', card], ['houseplan-panel.js', 'panel'], ['houseplan-assets/c-HASH.js', `chunk ${card}`]];
for (const [name, value] of entries) writeFileSync(join(dir, name), value);
const files = entries.map(([path, value]) => ({
path, sha256: sha256(value), rawBytes: value.length, gzipBytes: 1,
isEntry: !path.includes('/'), imports: [], dynamicImports: [],
}));
writeFileSync(join(dir, 'houseplan-assets.json'), `${JSON.stringify({
schema: 1, fingerprint: 'a'.repeat(64), entry: 'houseplan-card.js', panelEntry: 'houseplan-panel.js',
initialViewFiles: ['houseplan-card.js'], initialViewGzipBytes: 1,
initialPanelFiles: ['houseplan-panel.js'], initialPanelGzipBytes: 1,
initialPanelOnlyFiles: ['houseplan-panel.js'], initialPanelOnlyGzipBytes: 1, files,
})}\n`);
}
function fixture() {
const root = mkdtempSync(join(tmpdir(), 'hp-dev-build-test-'));
const origin = join(root, 'origin.git');
const work = join(root, 'work');
git(root, 'init', '--bare', '-q', '-b', 'dev', origin);
git(root, 'clone', '-q', origin, work);
git(work, 'checkout', '-q', '-b', 'dev');
mkdirSync(join(work, DEV_BUILD_TARGET), { recursive: true });
writeFileSync(join(work, DEV_BUILD_TARGET, 'houseplan-card.js'), 'card of the last beta');
writeFileSync(join(work, DEV_BUILD_TARGET, 'houseplan-assets.json'), '{}\n');
writeFileSync(join(work, 'src.ts'), 'v1');
git(work, 'add', '-A');
git(work, 'commit', '-q', '-m', 'base');
git(work, 'push', '-q', '-u', 'origin', 'dev');
return { root, origin, work, head: git(work, 'rev-parse', 'HEAD') };
}
test('#657 parseArgs: значения по умолчанию и обе формы флагов', () => {
assert.deepEqual(parseArgs(['--sha', 'x']), {
sha: 'x', dist: 'dist', remote: 'origin', branch: 'dev-build', expectRef: 'refs/heads/dev', dryRun: false,
});
assert.equal(parseArgs(['--sha=y', '--dry-run']).dryRun, true);
assert.equal(parseArgs(['--sha=y']).sha, 'y');
});
test('#657 dev-build: одна ветка без истории с бандлом головы dev и SHA источника', () => {
const { root, origin, work, head } = fixture();
try {
writeDist(work, 'fresh card');
const first = publishDevBuild({ cwd: work, sha: head, log: () => {} });
assert.equal(first.published, true);
const branchTip = git(origin, 'rev-parse', 'refs/heads/dev-build');
assert.equal(git(origin, 'rev-list', '--count', branchTip), '1', 'коммит без родителей');
assert.equal(git(origin, 'show', `${branchTip}:${DEV_BUILD_TARGET}/houseplan-card.js`), 'fresh card');
const marker = JSON.parse(git(origin, 'show', `${branchTip}:${DEV_BUILD_MARKER}`));
assert.equal(marker.source, head);
assert.equal(marker.files, 3);
assert.equal(git(work, 'status', '--porcelain', '--', DEV_BUILD_TARGET), '',
'рабочее дерево и индекс источника не тронуты');
assert.equal(git(origin, 'rev-parse', 'refs/heads/dev'), head, 'dev не тронут');
// Второй прогон заменяет ветку целиком — истории не копится.
writeDist(work, 'fresher card');
publishDevBuild({ cwd: work, sha: head, log: () => {} });
const second = git(origin, 'rev-parse', 'refs/heads/dev-build');
assert.notEqual(second, branchTip);
assert.equal(git(origin, 'rev-list', '--count', second), '1');
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('#657 dev-build: dev ушёл вперёд — прогон не пушит устаревшую сборку', () => {
const { root, origin, work, head } = fixture();
try {
writeFileSync(join(work, 'src.ts'), 'v2');
git(work, 'commit', '-qam', 'next');
git(work, 'push', '-q', 'origin', 'dev');
writeDist(work, 'card of the older head');
const lines = [];
const result = publishDevBuild({ cwd: work, sha: head, log: (l) => lines.push(l) });
assert.deepEqual({ published: result.published, reason: result.reason }, { published: false, reason: 'stale' });
assert.equal(git(origin, 'for-each-ref', 'refs/heads/dev-build'), '', 'ветка не создана');
assert.match(lines.join('\n'), /публикует следующий прогон/);
assert.throws(() => publishDevBuild({ cwd: work, sha: 'HEAD', log: () => {} }), /full commit SHA/);
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('#657 стенд: update-dev-bundle.sh ставит бандл dev-build поверх рабочей копии и умеет вернуть её до pull', () => {
const { root, origin, work, head } = fixture();
try {
writeDist(work, 'fresh card');
publishDevBuild({ cwd: work, sha: head, log: () => {} });
const stand = join(root, 'stand');
git(root, 'clone', '-q', '-b', 'dev', origin, stand);
writeFileSync(join(stand, DEV_BUILD_TARGET, 'stale-orphan.js'), 'left over');
const run = spawnSync('bash', [STAND, stand], { encoding: 'utf8', env: { ...process.env, ...ENV } });
assert.equal(run.status, 0, run.stderr);
assert.equal(readFileSync(join(stand, DEV_BUILD_TARGET, 'houseplan-card.js'), 'utf8'), 'fresh card');
assert.equal(existsSync(join(stand, DEV_BUILD_TARGET, 'houseplan-assets/c-HASH.js')), true);
assert.equal(existsSync(join(stand, DEV_BUILD_TARGET, 'stale-orphan.js')), false, 'каталог заменён целиком');
assert.match(run.stdout, new RegExp(head.slice(0, 8)));
const reset = spawnSync('bash', [STAND, '--reset', stand], { encoding: 'utf8', env: { ...process.env, ...ENV } });
assert.equal(reset.status, 0, reset.stderr);
assert.equal(git(stand, 'status', '--porcelain'), '', 'до pull рабочая копия чистая');
assert.equal(readFileSync(join(stand, DEV_BUILD_TARGET, 'houseplan-card.js'), 'utf8'), 'card of the last beta');
} finally { rmSync(root, { recursive: true, force: true }); }
});
+1 -1
View File
@@ -18,7 +18,7 @@ test('gate:small гоняет обязательную часть PROCESS §8 и
assert.ok(names.some((n) => n.includes('scripts/smoke-select.mjs --base origin/dev --head HEAD')));
const serial = postBuildSteps().map((s) => s.args.join(' '));
assert.equal(serial[0], 'test', 'юниты первыми читают уже готовый свежий dist');
assert.ok(serial.some((s) => s.includes('bundle-tree.mjs dist custom_components/houseplan/frontend')));
assert.ok(serial.some((s) => s.includes('bundle-policy.mjs --verify HEAD')));
assert.ok(serial.some((s) => s.includes('bundle:budget')));
assert.equal(parseArgs(['--base=abc']).base, 'abc');
assert.equal(parseArgs([]).base, 'origin/dev');
+14 -1
View File
@@ -11,7 +11,7 @@ import { fileURLToPath } from 'node:url';
import { CAPTURE_PROVENANCE_SCHEMA } from '../scripts/capture-environment.mjs';
import {
WSL_ATTESTATION_FILE, createWslAttestation, environmentRefusal,
repositoryRefusal, verifyWslAttestation,
repositoryRefusal, verifyWslAttestation, withoutBundlePaths,
} from '../scripts/golden-wsl-artifact.mjs';
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from '../demo/golden/matrix.mjs';
import { sourceFingerprint } from '../scripts/source-fingerprint.mjs';
@@ -204,3 +204,16 @@ test('#641: stale fingerprints, toolchain drift and undeclared diffs cannot be a
rmSync(unexpected, { recursive: true, force: true });
}
});
test('#657 WSL golden: пересобранный бандл не считается правкой источника, остальное — считается', () => {
// Первая строка приходит обрезанной (`command` делает trim) — без ведущего пробела.
const status = [
'M dist/houseplan-card.js',
'?? dist/houseplan-assets/houseplan-view-runtime-abc123.js',
' M custom_components/houseplan/frontend/houseplan-card.js',
' M src/editor-panel.ts',
'?? demo/golden/notes.txt',
].join('\n');
assert.equal(withoutBundlePaths(status), [' M src/editor-panel.ts', '?? demo/golden/notes.txt'].join('\n'));
assert.equal(withoutBundlePaths('M dist/houseplan-card.js'), '');
});
+2
View File
@@ -135,6 +135,8 @@ const HOOK_FILES = [
'scripts/branch-state.mjs',
'scripts/process-gate.mjs',
'scripts/validate-commit-provenance.mjs',
'scripts/bundle-policy.mjs', // #657: правило бандла в проверке происхождения
'scripts/bundle-tree.mjs',
'scripts/spawn-portable.mjs',
];
// Заглушка набора: код выхода и журнал вызовов задаёт тест.
+5 -3
View File
@@ -214,8 +214,10 @@ test('#643 process.yml: шаг «Привести ветку к dev» ребей
assert.match(step, /files=\$\(node "\$tools\/scripts\/rebase-generated\.mjs" --onto=origin\/dev\) \|\| code=\$\?/);
assert.match(step, /if \[ "\$code" -ne 0 \] && \[ "\$code" -ne 3 \]; then/, 'сбой помощника — не конфликт');
assert.match(step, /echo 'conflict=true'\n\s+echo 'conflicts<<EOF_FILES'/, 'выход conflict/conflicts на месте');
// Порядок: ребейз → индекс свеж → push с lease → ожидание ссылки.
const order = ['rebase-generated.mjs', '--commit-if-stale --issue="$NUM"', '--force-with-lease="refs/heads/$BRANCH:$before"', 'if [ "$seen" = "$after" ]; then settled=true'];
// Порядок: ребейз → push с lease → ожидание ссылки. Индекс в ветке задачи
// не пересобирается (#657, 1б): его догоняет слияние кандидата в dev.
assert.doesNotMatch(step, /--commit-if-stale/);
const order = ['rebase-generated.mjs', '--force-with-lease="refs/heads/$BRANCH:$before"', 'if [ "$seen" = "$after" ]; then settled=true'];
const at = order.map((needle) => step.indexOf(needle));
assert.ok(at.every((i) => i >= 0), JSON.stringify(at));
assert.deepEqual([...at].sort((a, b) => a - b), at, 'порядок шагов сохранён');
@@ -235,7 +237,7 @@ function runStepRebase(work) {
const body = step.slice(step.indexOf(' run: |\n') + ' run: |\n'.length)
.split('\n').map((line) => line.replace(/^ {10}/, '')).join('\n');
const from = body.indexOf('tools="$RUNNER_TEMP/rebase-tools"');
const to = body.indexOf('# #635 r2:');
const to = body.indexOf('# #657 (1б): индекс ревью в ветке');
assert.ok(from >= 0 && to > from, 'ребейзная часть шага найдена');
const temp = mkdtempSync(join(tmpdir(), 'hp-runner-'));
try {
+12 -14
View File
@@ -28,9 +28,6 @@ const git = (cwd, ...args) => execFileSync('git', args, {
cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
// Фальшивая сборка: dist/a.js = 'built:' + содержимое src/x.ts.
const SYNC = [process.execPath, '-e',
"const fs=require('fs');fs.writeFileSync('dist/a.js','built:'+fs.readFileSync('src/x.ts','utf8'))"];
function repo({ conflictInSrc, reviews = false }) {
const root = mkdtempSync(join(tmpdir(), 'hp-rebase-'));
@@ -80,10 +77,10 @@ test('splitConflicts делит пути на сгенерированные и
{ generated: [], regenerated: ['docs/reviews/INDEX.md'], manual: ['docs/reviews/CODE-REVIEW-9-r1.md'] });
});
test('#643 AC3: бандл и INDEX.md конфликтуют в одном коммите — бандл пересобран, индекс = пересборка каталога', () => {
test('#643 AC3/#657: бандл и INDEX.md конфликтуют в одном коммите — бандл = версия dev, индекс = пересборка каталога', () => {
const { root, work } = repo({ conflictInSrc: false, reviews: true });
try {
const result = rebaseOnDev({ cwd: work, syncCommand: SYNC, log: () => {} });
const result = rebaseOnDev({ cwd: work, log: () => {} });
assert.equal(result.rebased, true);
assert.ok(result.resolved.includes('docs/reviews/INDEX.md ← пересборка'), JSON.stringify(result.resolved));
assert.ok(result.resolved.some((r) => r.startsWith('dist/a.js')), 'бандл решён в той же остановке');
@@ -93,21 +90,22 @@ test('#643 AC3: бандл и INDEX.md конфликтуют в одном ко
assert.equal(index, buildIndex(join(work, 'docs', 'reviews')), 'индекс = пересборка, не версия dev и не ветки');
assert.match(index, /CODE-REVIEW-8-r1\.md/);
assert.match(index, /CODE-REVIEW-9-r1\.md/);
assert.equal(readFileSync(join(work, 'dist/a.js'), 'utf8'), 'built:branch\n');
assert.equal(readFileSync(join(work, 'dist/a.js'), 'utf8'), 'built:dev\n', '#657: бандл ветки не пересобирается');
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('конфликт только в бандле: ребейз доведён, бандл пересобран и зааменден (#479 AC5)', () => {
test('конфликт только в бандле: ребейз доведён, бандл = версия dev, без пересборки и амендинга (#479 AC5, #657)', () => {
const { root, work } = repo({ conflictInSrc: false });
try {
const result = rebaseOnDev({ cwd: work, syncCommand: SYNC, log: () => {} });
const result = rebaseOnDev({ cwd: work, log: () => {} });
assert.equal(result.rebased, true);
assert.equal(result.resolved.length, 1);
assert.equal(result.rebuilt, true);
assert.equal('rebuilt' in result, false, 'пересборки в ветке больше нет');
assert.equal(git(work, 'status', '--porcelain'), '');
assert.equal(git(work, 'rev-list', '--count', 'origin/dev..HEAD'), '1', 'один коммит ветки поверх dev');
assert.equal(git(work, 'rev-list', '--count', 'HEAD..origin/dev'), '0', 'dev полностью под веткой');
assert.equal(readFileSync(join(work, 'dist/a.js'), 'utf8'), 'built:branch\n', 'бандл собран из src ветки, а не dev');
assert.equal(readFileSync(join(work, 'dist/a.js'), 'utf8'), 'built:dev\n', 'бандл — версия dev, ветка его не несёт (#657)');
assert.equal(git(work, 'diff', '--name-only', 'origin/dev', 'HEAD', '--', 'dist'), '', 'бандл ветки совпал с dev');
assert.equal(readFileSync(join(work, 'src/z.ts'), 'utf8'), 'dev\n', 'правка dev на месте');
} finally { rmSync(root, { recursive: true, force: true }); }
});
@@ -116,7 +114,7 @@ test('конфликт в src/**: ребейз отменён, дерево и H
const { root, work } = repo({ conflictInSrc: true });
try {
const before = git(work, 'rev-parse', 'HEAD');
assert.throws(() => rebaseOnDev({ cwd: work, syncCommand: SYNC, log: () => {} }), /src\/y\.ts/);
assert.throws(() => rebaseOnDev({ cwd: work, log: () => {} }), /src\/y\.ts/);
assert.equal(git(work, 'rev-parse', 'HEAD'), before);
assert.equal(git(work, 'status', '--porcelain'), '');
assert.equal(readFileSync(join(work, 'src/y.ts'), 'utf8'), 'branch\n');
@@ -128,7 +126,7 @@ test('--dry-run предсказывает конфликт по бандлу и
try {
const before = git(work, 'rev-parse', 'HEAD');
const lines = [];
const result = rebaseOnDev({ cwd: work, dryRun: true, syncCommand: SYNC, log: (l) => lines.push(l) });
const result = rebaseOnDev({ cwd: work, dryRun: true, log: (l) => lines.push(l) });
assert.equal(result.rebased, false);
assert.deepEqual(result.predicted.generated, ['dist/a.js']);
assert.equal(git(work, 'rev-parse', 'HEAD'), before);
@@ -140,9 +138,9 @@ test('грязное дерево и ветка dev отвергаются до
const { root, work } = repo({ conflictInSrc: false });
try {
writeFileSync(join(work, 'src/x.ts'), 'dirty\n');
assert.throws(() => rebaseOnDev({ cwd: work, syncCommand: SYNC, log: () => {} }), /не чистое/);
assert.throws(() => rebaseOnDev({ cwd: work, log: () => {} }), /не чистое/);
git(work, 'checkout', '-q', '--', 'src/x.ts');
git(work, 'checkout', '-q', 'dev');
assert.throws(() => rebaseOnDev({ cwd: work, syncCommand: SYNC, log: () => {} }), /ветка dev/);
assert.throws(() => rebaseOnDev({ cwd: work, log: () => {} }), /ветка dev/);
} finally { rmSync(root, { recursive: true, force: true }); }
});
+9 -8
View File
@@ -97,8 +97,8 @@ test('#635 живой каталог docs/reviews: индекс свеж и по
// документы, приехавшие ребейзом, невидимы через индекс. Гейт — шаг Validate
// `reviews-index --check` на push в dev (см. комментарий в validate.yml, почему
// не на issue-ветках: их переписывает конвейер из ветки по умолчанию, и его
// коммиты индекс ветки знать не обязан). Свежесть держит `--commit-if-stale`
// после каждого ребейза конвейера; здесь — свидетель на проводке.
// коммиты индекс ветки знать не обязан). С #657 ветка задачи индекс не несёт
// вовсе; свежесть dev держит `--commit-if-stale` слияния кандидата.
test('#635 r3: свежесть индекса судится на dev, конвейер пересобирает индекс после своих ребейзов', () => {
const validate = readFileSync(new URL('../.github/workflows/validate.yml', import.meta.url), 'utf8');
const step = validate.slice(validate.indexOf('id: reviews_index'), validate.indexOf('id: workflow_sync'));
@@ -108,15 +108,16 @@ test('#635 r3: свежесть индекса судится на dev, конв
assert.match(validate, /\[ "\$REVIEWS_INDEX" = "skipped" \] \|\| check "индекс ревью совпадает с каталогом" "\$REVIEWS_INDEX"/);
});
test('#635 конвейер пересобирает индекс тем же коммитом, что и документ ревью', () => {
test('#635/#657 (1б): индекс пересобирается только коммитами, идущими в dev', () => {
const wf = new URL('../.github/workflows/_process.yml', import.meta.url);
const text = readFileSync(wf, 'utf8');
assert.match(text, /node scripts\/reviews-index\.mjs --dir=docs\/reviews\n\s+git add -- docs\/reviews\/INDEX\.md/);
// r2 H1: после приведения ветки к dev индекс пересобирается коммитом
// конвейера до фиксации материала; при слиянии — то же в merge-candidate.
// Публикация документа: индекс — тем же коммитом, только если цель — dev
// (ревью ТЗ). В ветку задачи — один документ.
assert.match(text, /if \[ -f "\$doc" \] && \[ "\$target" = "dev" \]; then\n\s+node scripts\/reviews-index\.mjs --dir=docs\/reviews\n\s+git add -- docs\/reviews\/INDEX\.md/);
// Приведение ветки к dev индекс больше не коммитит: ветка задачи его не несёт.
const rebase = text.slice(text.indexOf('- name: Привести ветку к dev'), text.indexOf('- name: Зафиксировать SHA материала ревью'));
assert.match(rebase, /node scripts\/reviews-index\.mjs --dir=docs\/reviews --commit-if-stale --issue="\$NUM"/);
assert.match(rebase, /NUM: \$\{\{ github\.event\.issue\.number \}\}/);
assert.doesNotMatch(rebase, /reviews-index\.mjs/, 'в ветке задачи индекс не пересобирается (#657)');
// Слияние кандидата в dev — единственная точка, где индекс задачи догоняет каталог.
const merge = readFileSync(new URL('../scripts/merge-candidate.mjs', import.meta.url), 'utf8');
assert.match(merge, /REVIEWS_INDEX_SCRIPT, '--dir=docs\/reviews', '--commit-if-stale'/);
});
+21 -2
View File
@@ -55,8 +55,8 @@ test('бандл собирается один раз и приезжает бр
// ~10 джобо-минут на каждом непереиспользованном прогоне.
const builds = [...workflow.matchAll(/^ +run: npm run (build|bundle:sync)$/gm)];
assert.equal(builds.length, 1, 'бандл должен собираться ровно в одной job');
assert.equal(workflow.match(/name: card-bundle/g)?.length, 4,
'один upload и три download артефакта бандла');
assert.equal(workflow.match(/name: card-bundle/g)?.length, 5,
'один upload и четыре download артефакта бандла (три браузерные job и стенд dev, #657)');
assert.equal(workflow.match(/name: card-test-build/g)?.length, 2,
'один upload и один download тестового дерева для smoke job');
assert.match(workflow, /name: card-test-build\n\s+path: test-build\//,
@@ -68,6 +68,25 @@ test('бандл собирается один раз и приезжает бр
assert.equal(workflow.match(/node scripts\/bundle-sync\.mjs/g)?.length, 3);
});
test('#657 копии бандла сверяются только на релизном коммите, стенд dev — из артефакта', () => {
const workflow = read('validate.yml');
const frontend = workflow.slice(workflow.indexOf('\n frontend:\n'), workflow.indexOf('\n dev_build:\n'));
assert.match(frontend, /name: Card bundle trees in sync\n\s+run: \|\n\s+node scripts\/bundle-policy\.mjs --verify HEAD/,
'решение «сверять ли закоммиченную копию» принимает bundle-policy, а не безусловный bundle-tree');
assert.doesNotMatch(frontend, /bundle-tree\.mjs dist custom_components/,
'безусловная сверка красила бы каждую задачу, не коммитящую бандл');
const devBuild = workflow.slice(workflow.indexOf('\n dev_build:\n'), workflow.indexOf('\n smoke:\n'));
assert.match(devBuild, /if: github\.event_name == 'push' && github\.ref == 'refs\/heads\/dev'/);
assert.match(devBuild, /needs: frontend/);
assert.match(devBuild, /contents: write/);
assert.match(devBuild, /name: card-bundle\n\s+path: dist/);
assert.match(devBuild, /node scripts\/dev-build\.mjs --sha "\$GITHUB_SHA" --dist dist/);
assert.match(devBuild, /continue-on-error: true/, 'сбой публикации для стенда не красит проверку кода');
const proof = workflow.slice(workflow.indexOf('\n proof:\n'));
assert.doesNotMatch(proof.split('\n').find((line) => /needs:/.test(line)) || '', /dev_build/,
'стенд — не вход доказательства');
});
test('предполётные проверки не прячут друг друга (#336)', () => {
const workflow = read('validate.yml');
const preflight = workflow.slice(