From 88c4e4e9ae0e6756247489222c0fca320ef47754 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 08:43:38 +0300 Subject: [PATCH] infra(process): bundle and review index change only on the way to dev (#657) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Решения владельца: 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 --- .github/workflows/_process.yml | 19 +-- .github/workflows/validate.yml | 34 ++++- AGENTS.md | 29 ++-- PROCESS.md | 21 +-- demo/stand/README.md | 16 +++ demo/stand/update-dev-bundle.sh | 49 +++++++ docs/DEVELOPMENT.md | 25 ++-- docs/process/AUTHOR.md | 5 +- docs/process/REVIEWER.md | 2 +- package.json | 2 + scripts/bundle-policy.mjs | 180 +++++++++++++++++++++++++ scripts/bundle-sync.mjs | 24 ++-- scripts/ci-proof.mjs | 6 +- scripts/dev-build.mjs | 125 +++++++++++++++++ scripts/gate-small.mjs | 4 +- scripts/golden-container.mjs | 3 + scripts/golden-wsl-artifact.mjs | 22 ++- scripts/mutation-registry.mjs | 67 +++++++++ scripts/pre-push-gate.mjs | 5 +- scripts/rebase-on-dev.mjs | 47 +++---- scripts/release-prerelease.mjs | 5 + scripts/sandbox-bootstrap.sh | 2 +- scripts/validate-commit-provenance.mjs | 9 +- test/bundle-policy.test.mjs | 156 +++++++++++++++++++++ test/bundle-sync.test.mjs | 14 +- test/bundle-tree-committed.test.mjs | 67 ++++++--- test/dev-build.test.mjs | 126 +++++++++++++++++ test/gate-small.test.mjs | 2 +- test/golden-wsl-artifact.test.mjs | 15 ++- test/pre-push-gate.test.mjs | 2 + test/rebase-generated.test.mjs | 8 +- test/rebase-on-dev.test.mjs | 26 ++-- test/reviews-index.test.mjs | 17 +-- test/validate-workflow.test.mjs | 23 +++- 34 files changed, 1021 insertions(+), 136 deletions(-) create mode 100755 demo/stand/update-dev-bundle.sh create mode 100644 scripts/bundle-policy.mjs create mode 100644 scripts/dev-build.mjs create mode 100644 test/bundle-policy.test.mjs create mode 100644 test/dev-build.test.mjs diff --git a/.github/workflows/_process.yml b/.github/workflows/_process.yml index dbe7457a..d4568db8 100644 --- a/.github/workflows/_process.yml +++ b/.github/workflows/_process.yml @@ -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 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 75d6b51f..4e5776ad 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 45aedb1c..1fceffd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/PROCESS.md b/PROCESS.md index e6a91a25..6faf6492 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -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`, вшитое число здесь трижды отставало от diff --git a/demo/stand/README.md b/demo/stand/README.md index 17db5b5f..6375f271 100644 --- a/demo/stand/README.md +++ b/demo/stand/README.md @@ -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 # before git pull: restore the tracked copy + git -C pull --ff-only + demo/stand/update-dev-bundle.sh # 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 diff --git a/demo/stand/update-dev-bundle.sh b/demo/stand/update-dev-bundle.sh new file mode 100755 index 00000000..a4e4c92f --- /dev/null +++ b/demo/stand/update-dev-bundle.sh @@ -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 # до pull: вернуть отслеживаемый бандл +# git -C pull --ff-only +# demo/stand/update-dev-bundle.sh # после 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] } +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})" diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 34bd1e15..193249af 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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. diff --git a/docs/process/AUTHOR.md b/docs/process/AUTHOR.md index b20efe80..dd3ffb69 100644 --- a/docs/process/AUTHOR.md +++ b/docs/process/AUTHOR.md @@ -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-гейты)). diff --git a/docs/process/REVIEWER.md b/docs/process/REVIEWER.md index 851d8226..36150b35 100644 --- a/docs/process/REVIEWER.md +++ b/docs/process/REVIEWER.md @@ -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 плюс вывод diff --git a/package.json b/package.json index 7c8eeba4..a92c833b 100755 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/bundle-policy.mjs b/scripts/bundle-policy.mjs new file mode 100644 index 00000000..1dc67058 --- /dev/null +++ b/scripts/bundle-policy.mjs @@ -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 [] # код 0 — сверять копии, 1 — нет + * node scripts/bundle-policy.mjs --verify [] # сборка 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 [] | --verify [] | --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; + } +} diff --git a/scripts/bundle-sync.mjs b/scripts/bundle-sync.mjs index 25b35593..8d1157b0 100644 --- a/scripts/bundle-sync.mjs +++ b/scripts/bundle-sync.mjs @@ -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)) { diff --git a/scripts/ci-proof.mjs b/scripts/ci-proof.mjs index e63882e7..8e150bb7 100644 --- a/scripts/ci-proof.mjs +++ b/scripts/ci-proof.mjs @@ -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; diff --git a/scripts/dev-build.mjs b/scripts/dev-build.mjs new file mode 100644 index 00000000..75097d96 --- /dev/null +++ b/scripts/dev-build.mjs @@ -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 [--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; + } +} diff --git a/scripts/gate-small.mjs b/scripts/gate-small.mjs index d94805ac..4f4a0777 100644 --- a/scripts/gate-small.mjs +++ b/scripts/gate-small.mjs @@ -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/. diff --git a/scripts/golden-container.mjs b/scripts/golden-container.mjs index 987d5808..904f17ed 100644 --- a/scripts/golden-container.mjs +++ b/scripts/golden-container.mjs @@ -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 = [ diff --git a/scripts/golden-wsl-artifact.mjs b/scripts/golden-wsl-artifact.mjs index 2c4a4d4d..c5356810 100644 --- a/scripts/golden-wsl-artifact.mjs +++ b/scripts/golden-wsl-artifact.mjs @@ -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'); diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 8cc402b0..5118012f 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -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', diff --git a/scripts/pre-push-gate.mjs b/scripts/pre-push-gate.mjs index 52b96236..53c1d025 100644 --- a/scripts/pre-push-gate.mjs +++ b/scripts/pre-push-gate.mjs @@ -38,9 +38,12 @@ * исполняемым диффом, которая не HEAD, отклоняется — иначе зелёный вердикт был * бы вынесен чужому дереву; обход назван в сообщении. * - * Бандл не собирается: `bundle-sync.mjs` раскладывает закоммиченный `dist`, а + * Бандл не собирается: `bundle-sync.mjs` раскладывает `dist` рабочего дерева, а * свежесть проверяет сам продукт — `assertFreshDemoBundle` внутри каждого смока * сверяет вшитый отпечаток с исходниками дерева и скажет, если нужна пересборка. + * С #657 закоммиченный `dist` законно отстаёт до кандидата беты: перед push + * ветки, трогающей исходники, соберите `npm run build` (в коммит сборка не + * идёт — `npm run bundle:clean`, если она попала в индекс). */ import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; diff --git a/scripts/rebase-on-dev.mjs b/scripts/rebase-on-dev.mjs index 3fcace3e..5340329d 100644 --- a/scripts/rebase-on-dev.mjs +++ b/scripts/rebase-on-dev.mjs @@ -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] diff --git a/scripts/release-prerelease.mjs b/scripts/release-prerelease.mjs index 378021bd..0b87faef 100644 --- a/scripts/release-prerelease.mjs +++ b/scripts/release-prerelease.mjs @@ -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 }; }; diff --git a/scripts/sandbox-bootstrap.sh b/scripts/sandbox-bootstrap.sh index c406d04d..286ff5b5 100755 --- a/scripts/sandbox-bootstrap.sh +++ b/scripts/sandbox-bootstrap.sh @@ -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() { diff --git a/scripts/validate-commit-provenance.mjs b/scripts/validate-commit-provenance.mjs index afe2bfb5..9deb371c 100644 --- a/scripts/validate-commit-provenance.mjs +++ b/scripts/validate-commit-provenance.mjs @@ -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- ')}`); } } diff --git a/test/bundle-policy.test.mjs b/test/bundle-policy.test.mjs new file mode 100644 index 00000000..4c9a6f64 --- /dev/null +++ b/test/bundle-policy.test.mjs @@ -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 }); } +}); diff --git a/test/bundle-sync.test.mjs b/test/bundle-sync.test.mjs index d6b86298..028cd552 100644 --- a/test/bundle-sync.test.mjs +++ b/test/bundle-sync.test.mjs @@ -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}`); diff --git a/test/bundle-tree-committed.test.mjs b/test/bundle-tree-committed.test.mjs index a0ec36c1..6d358aa8 100644 --- a/test/bundle-tree-committed.test.mjs +++ b/test/bundle-tree-committed.test.mjs @@ -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: собрано, но не закоммичено'); diff --git a/test/dev-build.test.mjs b/test/dev-build.test.mjs new file mode 100644 index 00000000..01b1eaa3 --- /dev/null +++ b/test/dev-build.test.mjs @@ -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 }); } +}); diff --git a/test/gate-small.test.mjs b/test/gate-small.test.mjs index b8fa791f..6ad22a1e 100644 --- a/test/gate-small.test.mjs +++ b/test/gate-small.test.mjs @@ -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'); diff --git a/test/golden-wsl-artifact.test.mjs b/test/golden-wsl-artifact.test.mjs index 03c2be65..5f2e408b 100644 --- a/test/golden-wsl-artifact.test.mjs +++ b/test/golden-wsl-artifact.test.mjs @@ -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'), ''); +}); diff --git a/test/pre-push-gate.test.mjs b/test/pre-push-gate.test.mjs index 3d39099a..284e3f89 100644 --- a/test/pre-push-gate.test.mjs +++ b/test/pre-push-gate.test.mjs @@ -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', ]; // Заглушка набора: код выхода и журнал вызовов задаёт тест. diff --git a/test/rebase-generated.test.mjs b/test/rebase-generated.test.mjs index 09f06aea..47cb230a 100644 --- a/test/rebase-generated.test.mjs +++ b/test/rebase-generated.test.mjs @@ -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< 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 { diff --git a/test/rebase-on-dev.test.mjs b/test/rebase-on-dev.test.mjs index 2a99ebbf..ee9ae0fe 100644 --- a/test/rebase-on-dev.test.mjs +++ b/test/rebase-on-dev.test.mjs @@ -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 }); } }); diff --git a/test/reviews-index.test.mjs b/test/reviews-index.test.mjs index 2a79cb6f..e30a3857 100644 --- a/test/reviews-index.test.mjs +++ b/test/reviews-index.test.mjs @@ -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'/); }); diff --git a/test/validate-workflow.test.mjs b/test/validate-workflow.test.mjs index 0f48d79d..0d0da46a 100644 --- a/test/validate-workflow.test.mjs +++ b/test/validate-workflow.test.mjs @@ -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(