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
+11 -8
View File
@@ -446,11 +446,12 @@ jobs:
printf 'конфликтуют:\n%s\n' "${files:-(git не назвал файлы)}"
exit 0
fi
# #635 r2: ребейз мог принести в docs/reviews документы других задач,
# а закоммиченный INDEX.md — снимок каталога — их не знает. Свежесть
# держит тест «индекс свеж» в Validate, поэтому индекс пересобирается
# здесь же, коммитом конвейера (класс C), до фиксации материала.
node scripts/reviews-index.mjs --dir=docs/reviews --commit-if-stale --issue="$NUM"
# #657 (1б): индекс ревью в ветке задачи не пересобирается — ни здесь,
# ни при публикации документа. Он пересобирается только коммитами,
# которые идут прямо в dev: слиянием кандидата (merge-candidate.mjs)
# и публикацией документа ревью ТЗ. Коммит индекса в ветке конфликтовал
# с документами соседей по построению (#643) и возвращал зелёные
# задачи на повторное ревью; гейт «индекс свеж» судит только dev.
# --force-with-lease с явным ожидаемым значением обязателен: между
# fetch и push автор мог запушить коммит, и слепой --force потерял бы
# его молча. Расхождение lease — падение прогона, а не предупреждение:
@@ -1375,9 +1376,11 @@ jobs:
# забрал бы всё, что там окажется, а после reset там не должно быть
# ничего постороннего — но полагаться на «не должно» здесь нельзя.
git add -- "$doc" 2>/dev/null || true
# #635: индекс ревью пересобирается тем же коммитом, что и документ —
# иначе он устаревает на первом же раунде. Генерируемый файл, класс C.
if [ -f "$doc" ]; then
# #635/#657 (1б): индекс пересобирается тем же коммитом, что и
# документ, только когда документ идёт прямо в dev (ревью ТЗ). В ветку
# задачи — один документ: индекс там конфликтовал бы с документами
# соседей в dev по построению; его пересоберёт слияние кандидата.
if [ -f "$doc" ] && [ "$target" = "dev" ]; then
node scripts/reviews-index.mjs --dir=docs/reviews
git add -- docs/reviews/INDEX.md
fi
+33 -1
View File
@@ -785,9 +785,12 @@ jobs:
- name: Build
run: npm run build
# Entry, manifest and every content-hashed chunk are one artifact (#337).
# #657: the committed bundle changes only in a release candidate, so the
# fresh build must equal it only there; elsewhere the committed copy
# legitimately lags and only the fresh dist is verified.
- name: Card bundle trees in sync
run: |
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
node scripts/bundle-policy.mjs --verify HEAD
npm run bundle:budget
# #624: `tsc --noUnusedLocals` чист вне порта/харнесса, шесть чисел
# связности монолита не выше scripts/monolith-baseline.json. После
@@ -819,6 +822,35 @@ jobs:
retention-days: 1
if-no-files-found: error
# #657 (2б): бандл в дереве dev меняет только кандидат беты, поэтому стенд
# dev.houseplan.tech берёт его не из дерева, а из этого артефакта: после
# зелёного фронтенда на push в dev собранный `dist/` публикуется в служебную
# ветку `dev-build` одним коммитом без истории (scripts/dev-build.mjs), хост
# забирает его demo/stand/update-dev-bundle.sh. Опубликуется только голова:
# если dev ушёл вперёд, пушит следующий прогон. Не вход доказательства:
# стенд — не гейт, и сбой публикации не красит проверку кода.
dev_build:
name: "Бандл головы dev для стенда"
needs: frontend
if: github.event_name == 'push' && github.ref == 'refs/heads/dev' && needs.frontend.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
continue-on-error: true
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
- name: Забрать собранный бандл
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: card-bundle
path: dist
- name: Опубликовать в dev-build
run: node scripts/dev-build.mjs --sha "$GITHUB_SHA" --dist dist
smoke:
name: Смоки в браузере (шард ${{ matrix.shard }} из 3)
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
+19 -10
View File
@@ -110,7 +110,7 @@ Start with the spec?" is the correct answer, not a smaller patch.
| **A — product** | `src/**`, `custom_components/houseplan/**/*.py`, `manifest.json`, `hacs.json`, i18n, `custom_components/**/translations/**` | yes |
| **B — gates and tooling** | `test/**`, `tests_backend/**`, `demo/**`, `scripts/**`, `.github/workflows/**`, `rollup.config.mjs`, `tsconfig*.json` | yes; may reuse the issue it covers |
| **C — documentation** | `docs/**`, `README*`, `CHANGELOG*`, `AGENTS.md` | not if it is part of its issue's DoD |
| **D — generated** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/golden/baselines/**` | never changes on its own. The stand copy `demo/srv/assets/**` is no longer committed (#255): build the complete tree with `npm run bundle:sync` |
| **D — generated** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/golden/baselines/**` | never changes on its own. The stand copy `demo/srv/assets/**` is no longer committed (#255): build it with `npm run bundle:sync`. The bundle (`dist/**`, `custom_components/houseplan/frontend/**`) changes only in a commit with a `Release:` trailer — the beta/release candidate, `npm run bundle:release` (#657); an ordinary task leaves it behind the sources and restores the build with `npm run bundle:clean` before committing |
The table above is a summary; `PROCESS.md` §1 is the authority and now covers the
configuration files this one omits — `package.json`, `package-lock.json`,
@@ -180,11 +180,11 @@ in between.
If the rebase conflicts the pipeline says so in the issue and sends the task back
to `S6-in-progress`. The verdict still stands: nothing needs reviewing again, the
remaining work is the rebase. When the conflict is only in the committed bundle
(`dist/**`, `custom_components/houseplan/frontend/**` — the usual case when two
tasks built it in parallel), run `node scripts/rebase-on-dev.mjs` (#479): it takes
`dev`'s copy through the rebase, rebuilds with `npm run bundle:sync` and amends
the result into your last commit; a conflict in `docs/reviews/INDEX.md` is
remaining work is the rebase. When the conflict is only in generated files, run
`node scripts/rebase-on-dev.mjs` (#479): a conflict in the committed bundle
(`dist/**`, `custom_components/houseplan/frontend/**` — possible only for a branch
started before #657, which stopped tasks from committing it) takes `dev`'s copy
and continues, with no rebuild and no amend; a conflict in `docs/reviews/INDEX.md` is
rebuilt from the directory (#643, the same helper the pipeline uses, so the
pipeline no longer bounces a task on it); a conflict anywhere else aborts and
leaves the tree as it was. Then push the branch and re-apply `S7-code-review`. When the
@@ -399,14 +399,23 @@ npm run inventory # the only correct way to get test counts
Never copy test counts into documents by hand; they go stale in days.
After building, keep the complete manifest-driven bundle trees in sync — CI
verifies every listed file byte-for-byte:
After building, lay the bundle out for the stand; only a candidate updates the
committed copy (#657):
```
npm run bundle:sync # dist → custom_components + demo/srv/assets (#255)
npm run bundle:budget # initial View graph <= 256000 B gzip (#337)
npm run bundle:sync # build + dist → demo/srv/assets (#255)
npm run bundle:clean # before an ordinary commit: dist back to the committed copy (#657)
npm run bundle:release # candidate only: build + dist → custom_components + demo/srv/assets
npm run bundle:budget # initial View graph <= 256000 B gzip (#337)
```
CI (`bundle-policy --verify`) checks the fresh build's integrity on every push
and compares it byte-for-byte with the committed copy only where the commit
changes the bundle or is a candidate. Publishing a beta additionally refuses a
committed bundle whose embedded source fingerprint differs from the tree. The
dev stand gets the head of `dev` from the Validate artifact via the `dev-build`
branch, not from the tree (`demo/stand/README.md`).
`npm run gate:small` runs the mandatory part of PROCESS §8 in one go (#479,
#576): build with typecheck, `no-new-any`, `no-new-private-writes` and `smoke-select` start in parallel;
unit tests follow the completed build because their bundle-contract witnesses
+12 -9
View File
@@ -50,7 +50,7 @@
| **A. Продукт** | `src/**`, `custom_components/houseplan/**/*.py`, `manifest.json`, `hacs.json`, `src/i18n/*.json`, `custom_components/**/translations/*` | **Да, обязательно.** Только из «Готово к разработке» или дальше |
| **B. Гейты и инструменты** | `test/**`, `tests_backend/**`, `demo/**`, `scripts/**`, весь `.github/**`, `.githooks/**`, `rollup.config.mjs`, `tsconfig*.json`, `package.json`, `package-lock.json`, `pytest.ini`, `.gitignore`, `.gitattributes` | **Да.** Может использовать issue того изменения, которое покрывает; самостоятельная работа над гейтом получает свой issue (тип `tech-debt`) |
| **C. Документация** | `docs/**`, `README*`, `CHANGELOG*`, `AGENTS.md`, `CONTRIBUTING.md`, `PROCESS*.md`, `LICENSE`, `(CODE\|SPEC)-REVIEW-*.md` | Документирование A/B в том же коммите — часть DoD своего issue. Самостоятельная работа над документацией — свой issue |
| **D. Сгенерированное** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/golden/baselines/**` (копия стенда `demo/srv/assets/houseplan-card.js` с #255 не коммитится вовсе) | Никогда не меняется само по себе. Коммит **только** класса D допустим лишь как релизный промоушен или как принятие эталонов с доказательством ревью |
| **D. Сгенерированное** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/golden/baselines/**` (копия стенда `demo/srv/assets/houseplan-card.js` с #255 не коммитится вовсе) | Никогда не меняется само по себе. Коммит **только** класса D допустим лишь как релизный промоушен или как принятие эталонов с доказательством ревью. **Бандл** (`dist/**`, `custom_components/houseplan/frontend/**`) с #657 меняет только коммит с трейлером `Release:` — кандидат беты или релиза (`npm run bundle:release`); в обычной задаче закоммиченный бандл законно отстаёт от исходников, сборка в коммит не идёт (`npm run bundle:clean`). Судит `validate-commit-provenance.mjs` — хук `commit-msg` и история в CI |
Практический смысл таблицы: «я только поправил тест» и «я только пересобрал
бандл» перестают быть лазейками.
@@ -363,10 +363,12 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
**Индекс документов ревью** — `docs/reviews/INDEX.md` (#635): одна строка на
документ — issue, этап, раунд, вердикт, число High/Medium, заголовки находок,
файлы из находок (искать по имени файла: `grep form-kit docs/reviews/INDEX.md`).
Файл генерируется `node scripts/reviews-index.mjs` и пересобирается конвейером
тем же коммитом, что публикует документ, а также после каждого его ребейза
(приведение к dev перед ревью, слияние кандидата — `--commit-if-stale`, коммит
класса C); руками не правится. Конфликт ребейза, в котором **все** пути —
Файл генерируется `node scripts/reviews-index.mjs` и пересобирается **только
коммитами, идущими в `dev`** (#657, решение 1б): слиянием кандидата после
ребейза (`--commit-if-stale`, коммит класса C) и публикацией документа ревью ТЗ
прямо в `dev`. В ветке задачи индекс не пересобирается — ни при приведении к
dev, ни при публикации документа код-ревью: иначе две параллельные задачи
конфликтуют на нём по построению. Руками не правится. Конфликт ребейза, в котором **все** пути —
`INDEX.md`, отказом не считается (#643): `scripts/rebase-generated.mjs`
пересобирает индекс по каталогу на остановке и продолжает ребейз — так делают
приведение к dev, слияние кандидата и авторский `rebase-on-dev.mjs`; индекс
@@ -388,7 +390,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
перечисляет только живые. Перенос — часть чеклиста стабильного релиза, не
отдельная задача.
Дешёвые гейты (`typecheck`, `test`, `build` со сверкой копий бандла) гоняются в
Дешёвые гейты (`typecheck`, `test`, `build` с проверкой целостности сборки, `bundle-policy --verify`) гоняются в
каждом раунде: код изменился, а стоят они минуты. Тяжёлые — по дельте (§10.2).
**Разбор остаётся полным**, если дельта не локальна: ребейз на ушедший вперёд
@@ -707,8 +709,9 @@ issue #NN
```
npx tsc --noEmit
npm test
npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js \
# копия стенда собирается `npm run bundle:sync`, в репозитории её нет (#255)
npm run build && node scripts/bundle-policy.mjs --verify HEAD
# сборка цела; копии сверяются только на кандидате (#657).
# Копия стенда — `npm run bundle:sync` (#255); перед коммитом `npm run bundle:clean`
node scripts/smoke-select.mjs --base origin/dev --head HEAD # какие смоки относятся к диффу
node demo/smoke_<целевые>.mjs
node scripts/no-new-any.mjs --base origin/dev --head HEAD # новый код не добавляет any
@@ -732,7 +735,7 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \
комментарии, строке или идентификаторе ложных срабатываний не даёт.
**Объём гейтов на код-ревью соразмерен задаче** (issue #127). Всегда:
`typecheck`, `npm test`, `npm run build` со сверкой трёх копий бандла, а при
`typecheck`, `npm test`, `npm run build` с `bundle-policy --verify` (копии сверяются на кандидате, #657), а при
любом diff'е по `src/**` — ещё и `node scripts/check-docs.mjs`. По
необходимости, определяемой diff'ом и AC: браузерные смоки (сколько их —
считает `ls demo/smoke_*.mjs | wc -l`, вшитое число здесь трижды отставало от
+16
View File
@@ -28,6 +28,22 @@ of the shipped integration.
is the dev-stand counterpart: a single `console.info` saying the dev stand
only resets on deploy.
- `update-dev-bundle.sh` — the dev stand's card bundle (#657). Since #657 the
committed bundle changes only in a beta/release candidate, so the `dev` tree
between betas carries the last beta's bundle. Validate publishes the bundle
it built for the head of `dev` into the orphan branch `dev-build` (one commit,
force-pushed, `DEV-BUILD.json` names the source SHA; `scripts/dev-build.mjs`).
The host deploy script must overlay it after `git pull`:
```sh
demo/stand/update-dev-bundle.sh --reset <checkout> # before git pull: restore the tracked copy
git -C <checkout> pull --ff-only
demo/stand/update-dev-bundle.sh <checkout> # after: frontend ← origin/dev-build
```
Until `/opt/hp/bin/hp-update-dev.sh` does this, the dev stand shows the last
beta's bundle instead of the head of `dev`.
## Why the manifests are templates
Both components ship their manifest as `manifest.template.json`, and
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Бандл головы dev для стенда разработки (#657).
#
# С #657 бандл в дереве dev меняется только релизным кандидатом, поэтому
# между бетами `custom_components/houseplan/frontend/` в dev — бандл последней
# беты. Свежий бандл головы dev Validate публикует в служебную ветку
# `dev-build` (scripts/dev-build.mjs): один коммит без истории с
# `custom_components/houseplan/frontend/**` и DEV-BUILD.json (SHA источника).
#
# Скрипт кладёт этот бандл поверх рабочей копии стенда. В
# /opt/hp/bin/hp-update-dev.sh — два вызова вокруг `git pull` dev:
#
# demo/stand/update-dev-bundle.sh --reset <checkout> # до pull: вернуть отслеживаемый бандл
# git -C <checkout> pull --ff-only
# demo/stand/update-dev-bundle.sh <checkout> # после pull, до перезапуска HA
#
# Первый вызов нужен, потому что наложенный бандл — локальная правка
# отслеживаемых файлов, и pull кандидата беты на ней остановился бы.
#
# Если dev-build отстаёт от головы dev (Validate ещё идёт или упал), скрипт
# предупреждает и всё равно ставит последний опубликованный бандл: он собран
# из чуть более старого dev, но из того же конвейера, а не из дерева беты.
set -euo pipefail
reset=false
if [ "${1:-}" = "--reset" ]; then reset=true; shift; fi
checkout=${1:?usage: update-dev-bundle.sh [--reset] <checkout-of-dev>}
remote=${HP_DEV_BUILD_REMOTE:-origin}
branch=${HP_DEV_BUILD_BRANCH:-dev-build}
target=custom_components/houseplan/frontend
cd "$checkout"
if $reset; then
git checkout -- "$target"
git clean -fdq -- "$target"
echo "update-dev-bundle: $target возвращён к закоммиченному"
exit 0
fi
git fetch --quiet "$remote" "+refs/heads/$branch:refs/remotes/$remote/$branch"
built=$(git show "$remote/$branch:DEV-BUILD.json" | sed -n 's/.*"source": *"\([0-9a-f]\{40\}\)".*/\1/p')
head=$(git rev-parse HEAD)
if [ "$built" != "$head" ]; then
echo "update-dev-bundle: $branch собран из ${built:0:8}, а рабочая копия на ${head:0:8} — ставлю последний опубликованный" >&2
fi
# Заменить каталог целиком: переименованные content-hashed чанки не должны
# копиться от сборки к сборке.
rm -rf "$target"
git archive "$remote/$branch" "$target" | tar -x
echo "update-dev-bundle: $target ← $branch (${built:0:8})"
+17 -8
View File
@@ -218,12 +218,17 @@ new `size-pack` with 467.63 MiB; the accepted upper bound is 487.63 MiB.
- IMPORTANT (audit lesson): the rollup typescript plugin reports a syntax error as a WARNING and still
builds the bundle — a truncated file can "pass". That is why the build starts with `tsc --noEmit`,
which fails on such errors. Always build with `npm run build`, never bare `rollup -c`.
- Before committing a frontend source change, run `npm run bundle:sync`. Rollup writes
- The committed bundle changes only in a beta/release candidate (#657). Rollup writes
`dist/houseplan-card.js`, `dist/houseplan-assets.json` and content-hashed chunks under
`dist/houseplan-assets/`; the command synchronizes that complete tree to the committed
integration snapshot and the untracked demo copy, then verifies every manifest hash.
`node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend` is the
read-only parity check used by CI and release automation.
`dist/houseplan-assets/`; `npm run bundle:sync` builds and lays that tree out into the
untracked demo copy, and `npm run bundle:clean` restores the tracked `dist/` before an
ordinary commit (the `commit-msg` hook refuses bundle paths without a `Release:`
trailer). The candidate runs `npm run bundle:release`, which also updates the
integration snapshot `custom_components/houseplan/frontend`.
`node scripts/bundle-policy.mjs --verify HEAD` is the check CI and `gate:small` run:
build integrity always, byte parity with the committed copy only on a commit that
changes the bundle or is a candidate; `node scripts/bundle-tree.mjs dist
custom_components/houseplan/frontend` stays the read-only parity check of release automation.
- The first-space/import dialog is a separate `houseplan-onboarding-runtime-*`
chunk. Do not fold it into `houseplan-editor-runtime-*`: empty-install
onboarding is a View prerequisite, while a configured View must request
@@ -335,9 +340,11 @@ commands and the explicit review workflow are documented in
```bash
cd /tmp/hpc && npm ci # once
npm run bundle:sync # build + entry/manifest/chunks → integration + demo
npm run bundle:sync # build + entry/manifest/chunks → demo
npm run bundle:budget # initial View graph must stay <= 256000 B gzip
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
npm run bundle:clean # before an ordinary commit (#657)
npm run bundle:release # candidate only: also → custom_components/houseplan/frontend
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend # candidate parity
```
## Deployment to the dacha (ha.jbstudio.pro)
@@ -525,7 +532,9 @@ edits — not a commit, not a merge (that is decided in `integrate` from the sea
### Primary prerelease path
Prepare the candidate as usual: synchronize every version field, add dated RU
and EN changelog sections, update the production bundle snapshots and write the
and EN changelog sections, update the production bundle snapshots with
`npm run bundle:release` (since #657 the only commit that may change them; it
carries the `Release:` trailer) and write the
short bilingual body in `docs/RELEASE-NOTES.md`. That file is the one current
instance of the canonical `## Основное` / `## Highlights` template; its two
changelog links must be pinned to the new tag.
+4 -1
View File
@@ -131,11 +131,14 @@
## Гейты перед хендоффом
- Минимальный набор по изменённым поверхностям: `npx tsc --noEmit`,
`npm test`, `npm run build` со сверкой копий бандла, `smoke-select` и
`npm test`, `npm run build` + `bundle-policy --verify`, `smoke-select` и
целевые смоки, `no-new-any`; по диффу — `golden:verify`, `check-docs`,
`model-invariants`, `pytest tests_backend`, junction parity. Команды —
в каноне ([§8](../../PROCESS.md#8-гейты)); `npm run gate:small` собирает
обязательную часть (`AGENTS.md`, «Gates»).
- Бандл в коммит задачи не идёт: сборка переписывает отслеживаемый `dist/`,
перед коммитом — `npm run bundle:clean`; хук `commit-msg` отклоняет пути
бандла без трейлера `Release:` (#657, [§1](../../PROCESS.md#1-основное-правило)).
- Новый код не добавляет `any`: гейт судит добавленные строки; исключение —
`// any-ok: <конкретная причина>` на той же строке
([§8](../../PROCESS.md#8-гейты)).
+1 -1
View File
@@ -71,7 +71,7 @@
## Объём гейтов
- Всегда: `typecheck`, `npm test`, `npm run build` со сверкой копий бандла; при
- Всегда: `typecheck`, `npm test`, `npm run build` + `bundle-policy --verify` (копии сверяются только на кандидате, #657); при
диффе по `src/**` — ещё `node scripts/check-docs.mjs`. Зелёный Validate на
SHA материала подтверждает дешёвые гейты ([§8](../../PROCESS.md#8-гейты)).
- По диффу и AC: смоки — названные в AC плюс вывод
+2
View File
@@ -11,6 +11,8 @@
"test": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/*.test.mjs",
"benchmark:wall-model": "tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node scripts/benchmark-wall-segment-model.mjs",
"bundle:sync": "npm run build && node scripts/bundle-sync.mjs",
"bundle:release": "npm run build && node scripts/bundle-sync.mjs --release",
"bundle:clean": "node scripts/bundle-policy.mjs --clean",
"gate:small": "node scripts/gate-small.mjs",
"test:chunk": "node scripts/test-chunk.mjs",
"toolchain:check": "node scripts/toolchain-pins.mjs --check",
+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- ')}`);
}
}
+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(