Files
houseplan-card/scripts/dev-build.mjs
T
Claudeandclaude[bot] 88c4e4e9ae 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
2026-09-26 07:28:23 +00:00

126 lines
6.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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;
}
}