mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
infra(process): bundle and review index change only on the way to dev (#657)
Решения владельца: 1б — индекс ревью не пересобирается в ветке задачи, только коммитами, идущими в dev; 2б — бандл меняет только кандидат беты/релиза, стенд dev берёт его из артефакта Validate. - scripts/bundle-policy.mjs: коммит, трогающий dist/** или custom_components/houseplan/frontend/**, обязан нести Release: (хук commit-msg и история в CI через validate-commit-provenance; коммиты с датой автора до 2026-09-27 не судятся); --verify судит целостность свежей сборки всегда, побайтовую сверку с закоммиченной копией — только на коммите, меняющем бандл, или кандидате; --clean. - release-prerelease: публикация отказывает, если отпечаток исходников в закоммиченном манифесте не равен отпечатку дерева (хотфикс поверх кандидата без пересборки). - bundle-sync: по умолчанию только demo/srv/assets; --release (npm run bundle:release) — ещё и custom_components. - rebase-on-dev: конфликт в бандле берёт копию dev, без пересборки и amend. - validate.yml: job dev_build публикует card-bundle головы dev в сиротскую ветку dev-build (scripts/dev-build.mjs); стенд накладывает её demo/stand/update-dev-bundle.sh. - _process.yml: индекс ревью больше не пересобирается при приведении к dev и при публикации документа в ветку задачи. - golden-wsl-artifact/golden-container: сборка перед съёмкой не считается правкой источника, после — bundle:clean. - test/bundle-tree-committed: судит закоммиченный снимок, не диск. - 11 мутантов в реестре; PROCESS/AGENTS/DEVELOPMENT/AUTHOR/REVIEWER. Issue: #657 User-Visible: no
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -110,7 +110,7 @@ Start with the spec?" is the correct answer, not a smaller patch.
|
||||
| **A — product** | `src/**`, `custom_components/houseplan/**/*.py`, `manifest.json`, `hacs.json`, i18n, `custom_components/**/translations/**` | yes |
|
||||
| **B — gates and tooling** | `test/**`, `tests_backend/**`, `demo/**`, `scripts/**`, `.github/workflows/**`, `rollup.config.mjs`, `tsconfig*.json` | yes; may reuse the issue it covers |
|
||||
| **C — documentation** | `docs/**`, `README*`, `CHANGELOG*`, `AGENTS.md` | not if it is part of its issue's DoD |
|
||||
| **D — generated** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/golden/baselines/**` | never changes on its own. The stand copy `demo/srv/assets/**` is no longer committed (#255): build the complete tree with `npm run bundle:sync` |
|
||||
| **D — generated** | `dist/**`, `custom_components/houseplan/frontend/**`, `demo/golden/baselines/**` | never changes on its own. The stand copy `demo/srv/assets/**` is no longer committed (#255): build it with `npm run bundle:sync`. The bundle (`dist/**`, `custom_components/houseplan/frontend/**`) changes only in a commit with a `Release:` trailer — the beta/release candidate, `npm run bundle:release` (#657); an ordinary task leaves it behind the sources and restores the build with `npm run bundle:clean` before committing |
|
||||
|
||||
The table above is a summary; `PROCESS.md` §1 is the authority and now covers the
|
||||
configuration files this one omits — `package.json`, `package-lock.json`,
|
||||
@@ -180,11 +180,11 @@ in between.
|
||||
|
||||
If the rebase conflicts the pipeline says so in the issue and sends the task back
|
||||
to `S6-in-progress`. The verdict still stands: nothing needs reviewing again, the
|
||||
remaining work is the rebase. When the conflict is only in the committed bundle
|
||||
(`dist/**`, `custom_components/houseplan/frontend/**` — the usual case when two
|
||||
tasks built it in parallel), run `node scripts/rebase-on-dev.mjs` (#479): it takes
|
||||
`dev`'s copy through the rebase, rebuilds with `npm run bundle:sync` and amends
|
||||
the result into your last commit; a conflict in `docs/reviews/INDEX.md` is
|
||||
remaining work is the rebase. When the conflict is only in generated files, run
|
||||
`node scripts/rebase-on-dev.mjs` (#479): a conflict in the committed bundle
|
||||
(`dist/**`, `custom_components/houseplan/frontend/**` — possible only for a branch
|
||||
started before #657, which stopped tasks from committing it) takes `dev`'s copy
|
||||
and continues, with no rebuild and no amend; a conflict in `docs/reviews/INDEX.md` is
|
||||
rebuilt from the directory (#643, the same helper the pipeline uses, so the
|
||||
pipeline no longer bounces a task on it); a conflict anywhere else aborts and
|
||||
leaves the tree as it was. Then push the branch and re-apply `S7-code-review`. When the
|
||||
@@ -399,14 +399,23 @@ npm run inventory # the only correct way to get test counts
|
||||
|
||||
Never copy test counts into documents by hand; they go stale in days.
|
||||
|
||||
After building, keep the complete manifest-driven bundle trees in sync — CI
|
||||
verifies every listed file byte-for-byte:
|
||||
After building, lay the bundle out for the stand; only a candidate updates the
|
||||
committed copy (#657):
|
||||
|
||||
```
|
||||
npm run bundle:sync # dist → custom_components + demo/srv/assets (#255)
|
||||
npm run bundle:budget # initial View graph <= 256000 B gzip (#337)
|
||||
npm run bundle:sync # build + dist → demo/srv/assets (#255)
|
||||
npm run bundle:clean # before an ordinary commit: dist back to the committed copy (#657)
|
||||
npm run bundle:release # candidate only: build + dist → custom_components + demo/srv/assets
|
||||
npm run bundle:budget # initial View graph <= 256000 B gzip (#337)
|
||||
```
|
||||
|
||||
CI (`bundle-policy --verify`) checks the fresh build's integrity on every push
|
||||
and compares it byte-for-byte with the committed copy only where the commit
|
||||
changes the bundle or is a candidate. Publishing a beta additionally refuses a
|
||||
committed bundle whose embedded source fingerprint differs from the tree. The
|
||||
dev stand gets the head of `dev` from the Validate artifact via the `dev-build`
|
||||
branch, not from the tree (`demo/stand/README.md`).
|
||||
|
||||
`npm run gate:small` runs the mandatory part of PROCESS §8 in one go (#479,
|
||||
#576): build with typecheck, `no-new-any`, `no-new-private-writes` and `smoke-select` start in parallel;
|
||||
unit tests follow the completed build because their bundle-contract witnesses
|
||||
|
||||
+12
-9
@@ -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`, вшитое число здесь трижды отставало от
|
||||
|
||||
@@ -28,6 +28,22 @@ of the shipped integration.
|
||||
is the dev-stand counterpart: a single `console.info` saying the dev stand
|
||||
only resets on deploy.
|
||||
|
||||
- `update-dev-bundle.sh` — the dev stand's card bundle (#657). Since #657 the
|
||||
committed bundle changes only in a beta/release candidate, so the `dev` tree
|
||||
between betas carries the last beta's bundle. Validate publishes the bundle
|
||||
it built for the head of `dev` into the orphan branch `dev-build` (one commit,
|
||||
force-pushed, `DEV-BUILD.json` names the source SHA; `scripts/dev-build.mjs`).
|
||||
The host deploy script must overlay it after `git pull`:
|
||||
|
||||
```sh
|
||||
demo/stand/update-dev-bundle.sh --reset <checkout> # before git pull: restore the tracked copy
|
||||
git -C <checkout> pull --ff-only
|
||||
demo/stand/update-dev-bundle.sh <checkout> # after: frontend ← origin/dev-build
|
||||
```
|
||||
|
||||
Until `/opt/hp/bin/hp-update-dev.sh` does this, the dev stand shows the last
|
||||
beta's bundle instead of the head of `dev`.
|
||||
|
||||
## Why the manifests are templates
|
||||
|
||||
Both components ship their manifest as `manifest.template.json`, and
|
||||
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# Бандл головы dev для стенда разработки (#657).
|
||||
#
|
||||
# С #657 бандл в дереве dev меняется только релизным кандидатом, поэтому
|
||||
# между бетами `custom_components/houseplan/frontend/` в dev — бандл последней
|
||||
# беты. Свежий бандл головы dev Validate публикует в служебную ветку
|
||||
# `dev-build` (scripts/dev-build.mjs): один коммит без истории с
|
||||
# `custom_components/houseplan/frontend/**` и DEV-BUILD.json (SHA источника).
|
||||
#
|
||||
# Скрипт кладёт этот бандл поверх рабочей копии стенда. В
|
||||
# /opt/hp/bin/hp-update-dev.sh — два вызова вокруг `git pull` dev:
|
||||
#
|
||||
# demo/stand/update-dev-bundle.sh --reset <checkout> # до pull: вернуть отслеживаемый бандл
|
||||
# git -C <checkout> pull --ff-only
|
||||
# demo/stand/update-dev-bundle.sh <checkout> # после pull, до перезапуска HA
|
||||
#
|
||||
# Первый вызов нужен, потому что наложенный бандл — локальная правка
|
||||
# отслеживаемых файлов, и pull кандидата беты на ней остановился бы.
|
||||
#
|
||||
# Если dev-build отстаёт от головы dev (Validate ещё идёт или упал), скрипт
|
||||
# предупреждает и всё равно ставит последний опубликованный бандл: он собран
|
||||
# из чуть более старого dev, но из того же конвейера, а не из дерева беты.
|
||||
set -euo pipefail
|
||||
|
||||
reset=false
|
||||
if [ "${1:-}" = "--reset" ]; then reset=true; shift; fi
|
||||
checkout=${1:?usage: update-dev-bundle.sh [--reset] <checkout-of-dev>}
|
||||
remote=${HP_DEV_BUILD_REMOTE:-origin}
|
||||
branch=${HP_DEV_BUILD_BRANCH:-dev-build}
|
||||
target=custom_components/houseplan/frontend
|
||||
|
||||
cd "$checkout"
|
||||
if $reset; then
|
||||
git checkout -- "$target"
|
||||
git clean -fdq -- "$target"
|
||||
echo "update-dev-bundle: $target возвращён к закоммиченному"
|
||||
exit 0
|
||||
fi
|
||||
git fetch --quiet "$remote" "+refs/heads/$branch:refs/remotes/$remote/$branch"
|
||||
built=$(git show "$remote/$branch:DEV-BUILD.json" | sed -n 's/.*"source": *"\([0-9a-f]\{40\}\)".*/\1/p')
|
||||
head=$(git rev-parse HEAD)
|
||||
if [ "$built" != "$head" ]; then
|
||||
echo "update-dev-bundle: $branch собран из ${built:0:8}, а рабочая копия на ${head:0:8} — ставлю последний опубликованный" >&2
|
||||
fi
|
||||
# Заменить каталог целиком: переименованные content-hashed чанки не должны
|
||||
# копиться от сборки к сборке.
|
||||
rm -rf "$target"
|
||||
git archive "$remote/$branch" "$target" | tar -x
|
||||
echo "update-dev-bundle: $target ← $branch (${built:0:8})"
|
||||
+17
-8
@@ -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.
|
||||
|
||||
@@ -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-гейты)).
|
||||
|
||||
@@ -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 плюс вывод
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Бандл в `dev` меняется только релизным кандидатом (#657, решение 2б).
|
||||
*
|
||||
* До #657 каждая задача пересобирала и коммитила бандл (`dist/**`,
|
||||
* `custom_components/houseplan/frontend/**`, класс D): за неделю 23–25.09 —
|
||||
* 34 коммита из 199, 16 MiB из ≈ 40 MiB прироста истории, потому что
|
||||
* content-hashed чанки не дельтируются между версиями. Две задачи, собравшие
|
||||
* бандл параллельно, конфликтовали на нём по построению.
|
||||
*
|
||||
* Теперь закоммиченный бандл — снимок последнего кандидата беты или релиза:
|
||||
*
|
||||
* - коммит, который трогает пути бандла, обязан нести трейлер `Release:`;
|
||||
* любой другой отклоняется хуком `commit-msg` и проверкой истории в CI;
|
||||
* - сверка «собранный = закоммиченный» (`bundle-tree dist frontend`) судит
|
||||
* только коммит, который бандл меняет, и кандидат (`Release v… candidate`),
|
||||
* даже если бандл в нём забыли пересобрать; на остальных проверяется
|
||||
* целостность свежей сборки — закоммиченная копия законно отстаёт.
|
||||
* Приёмка эталонов тоже несёт `Release:`, но бандл не трогает и после
|
||||
* хотфиксов к кандидату сверяться с ним не может;
|
||||
* - стенд `dev.houseplan.tech` берёт бандл из артефакта Validate
|
||||
* (`scripts/dev-build.mjs`, ветка `dev-build`), а не из дерева.
|
||||
*
|
||||
* node scripts/bundle-policy.mjs --must-match [<ref>] # код 0 — сверять копии, 1 — нет
|
||||
* node scripts/bundle-policy.mjs --verify [<ref>] # сборка dist цела; копии равны, если --must-match
|
||||
* node scripts/bundle-policy.mjs --clean # вернуть бандл к закоммиченному (npm run bundle:clean)
|
||||
*
|
||||
* Само правило коммита исполняет `validate-commit-provenance.mjs` — в хуке
|
||||
* `commit-msg` по индексу и в CI по диапазону истории.
|
||||
*
|
||||
* История до правила не переписывается: коммиты, написанные раньше
|
||||
* `BUNDLE_RELEASE_ONLY_SINCE`, судятся по-старому — иначе ветка, начатая до
|
||||
* #657 и уже собравшая бандл, не прошла бы приведение к `dev`.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { compareBundleTrees, verifyBundleTree } from './bundle-tree.mjs';
|
||||
|
||||
export const BUNDLE_ROOTS = Object.freeze(['dist/', 'custom_components/houseplan/frontend/']);
|
||||
|
||||
/** С этого момента (дата автора коммита) правило судит и историю. */
|
||||
export const BUNDLE_RELEASE_ONLY_SINCE = '2026-09-27T00:00:00Z';
|
||||
|
||||
export const BUNDLE_RELEASE_ONLY_ERROR = 'bundle (dist/**, custom_components/houseplan/frontend/**) changes only in a '
|
||||
+ "commit with a 'Release: vX.Y.Z' trailer (#657): restore it with `npm run bundle:clean`";
|
||||
|
||||
export const isBundlePath = (path) => {
|
||||
const normalized = String(path || '').replaceAll('\\', '/');
|
||||
return BUNDLE_ROOTS.some((root) => normalized.startsWith(root));
|
||||
};
|
||||
|
||||
const TRAILER = /^([A-Za-z][A-Za-z0-9-]*):\s*(.*?)\s*$/;
|
||||
|
||||
/** Значения трейлера `Release:` в терминальном блоке сообщения. */
|
||||
export function releaseTrailers(message) {
|
||||
const lines = String(message || '')
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.split('\n')
|
||||
.filter((line) => !line.startsWith('#'));
|
||||
while (lines.length && !lines.at(-1).trim()) lines.pop();
|
||||
const values = [];
|
||||
for (let index = lines.length - 1; index >= 0; index--) {
|
||||
const match = lines[index].match(TRAILER);
|
||||
if (!match) break;
|
||||
if (match[1] === 'Release' && match[2]) values.unshift(match[2]);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export const isReleaseMessage = (message) => releaseTrailers(message).length > 0;
|
||||
|
||||
/**
|
||||
* Ошибки правила для одного коммита. `authorDate` передаётся только при
|
||||
* проверке истории: коммит раньше `since` правилом не судится.
|
||||
*/
|
||||
export function bundleCommitErrors(message, files = [], {
|
||||
authorDate = null, since = BUNDLE_RELEASE_ONLY_SINCE,
|
||||
} = {}) {
|
||||
if (!files.some(isBundlePath)) return [];
|
||||
if (authorDate && Date.parse(authorDate) < Date.parse(since)) return [];
|
||||
return isReleaseMessage(message) ? [] : [BUNDLE_RELEASE_ONLY_ERROR];
|
||||
}
|
||||
|
||||
/**
|
||||
* Подпись кандидата беты/релиза: `Release vX.Y.Z[-beta.N] candidate` или
|
||||
* `Prepare vX.Y.Z-beta.N` (обе формы есть в истории). Это ранний сигнал
|
||||
* Validate; окончательно свежесть бандла судит публикация —
|
||||
* `assertCommittedBundleFresh` в `release-prerelease.mjs`, потому что
|
||||
* вершиной беты может оказаться и хотфикс поверх кандидата.
|
||||
*/
|
||||
export const isCandidateSubject = (subject) => /^(Release|Prepare) v\d/.test(String(subject || ''));
|
||||
|
||||
/**
|
||||
* Закоммиченный бандл собран из этого же дерева: отпечаток исходников,
|
||||
* вшитый сборкой в манифест, равен отпечатку дерева публикуемого SHA.
|
||||
* С #657 это единственное, что не даёт выпустить бету со старым бандлом,
|
||||
* если после кандидата в `dev` ушёл коммит без пересборки.
|
||||
*/
|
||||
export function assertCommittedBundleFresh(manifest, expectedFingerprint) {
|
||||
const actual = manifest?.fingerprint;
|
||||
if (!actual || actual !== expectedFingerprint) {
|
||||
throw new Error(`Committed bundle is stale: manifest fingerprint ${String(actual).slice(0, 12)} `
|
||||
+ `≠ source ${String(expectedFingerprint).slice(0, 12)}; rebuild the candidate with \`npm run bundle:release\` (#657)`);
|
||||
}
|
||||
return actual;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обязан ли закоммиченный бандл этого коммита совпасть со свежей сборкой:
|
||||
* да, если коммит бандл меняет (значит, он релизный по правилу выше) или
|
||||
* это кандидат — у кандидата бандл обязан быть свежим, даже если его забыли.
|
||||
*/
|
||||
export function committedBundleMustMatch({ subject = '', files = [] } = {}) {
|
||||
return files.some(isBundlePath) || isCandidateSubject(subject);
|
||||
}
|
||||
|
||||
function git(args, cwd) {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
|
||||
}
|
||||
|
||||
/** Тема и пути коммита. В shallow-клоне без родителя diff-tree выдал бы всё дерево. */
|
||||
function commitShape(ref, cwd) {
|
||||
const parents = git(['rev-list', '--parents', '-n', '1', ref], cwd).split(/\s+/).slice(1);
|
||||
if (!parents.length && git(['rev-parse', '--is-shallow-repository'], cwd) === 'true') {
|
||||
throw new Error(`${ref}: shallow-клон без родителя — пути коммита не определить (нужен fetch-depth: 0)`);
|
||||
}
|
||||
const subject = git(['show', '-s', '--format=%s', ref], cwd);
|
||||
const files = git(['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', ref], cwd)
|
||||
.split('\n').filter(Boolean);
|
||||
return { subject, files };
|
||||
}
|
||||
|
||||
function main(argv, cwd = process.cwd()) {
|
||||
const at = argv.indexOf('--must-match');
|
||||
if (at >= 0) {
|
||||
const ref = argv[at + 1] && !argv[at + 1].startsWith('--') ? argv[at + 1] : 'HEAD';
|
||||
const must = committedBundleMustMatch(commitShape(ref, cwd));
|
||||
console.log(must
|
||||
? `${ref}: коммит меняет бандл или это кандидат — закоммиченные копии обязаны совпасть со сборкой`
|
||||
: `${ref}: бандл не меняется — закоммиченная копия законно отстаёт, судится только свежая сборка (#657)`);
|
||||
return must ? 0 : 1;
|
||||
}
|
||||
const verifyAt = argv.indexOf('--verify');
|
||||
if (verifyAt >= 0) {
|
||||
// Одна точка решения для Validate и gate:small: свежая сборка обязана
|
||||
// быть целой всегда, а совпадать с закоммиченной копией — только там,
|
||||
// где коммит бандл меняет или объявлен кандидатом.
|
||||
const ref = argv[verifyAt + 1] && !argv[verifyAt + 1].startsWith('--') ? argv[verifyAt + 1] : 'HEAD';
|
||||
if (committedBundleMustMatch(commitShape(ref, cwd))) {
|
||||
const manifest = compareBundleTrees(resolve(cwd, 'dist'), resolve(cwd, 'custom_components/houseplan/frontend'));
|
||||
console.log(`${ref}: релизный коммит — dist и custom_components/houseplan/frontend совпадают (${manifest.files.length} ассетов)`);
|
||||
} else {
|
||||
const manifest = verifyBundleTree(resolve(cwd, 'dist'));
|
||||
console.log(`${ref}: свежая сборка dist цела (${manifest.files.length} ассетов); закоммиченная копия не сверяется — бандл меняет только кандидат (#657)`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (argv.includes('--clean')) {
|
||||
// Сборка переписывает отслеживаемый dist/ (и custom_components при
|
||||
// --release): перед обычным коммитом это вернуть. Новые чанки с новыми
|
||||
// хешами — неотслеживаемые файлы, их убирает clean в тех же корнях.
|
||||
const roots = BUNDLE_ROOTS.map((root) => root.replace(/\/$/, ''));
|
||||
git(['checkout', '--', ...roots], cwd);
|
||||
git(['clean', '-fdq', '--', ...roots], cwd);
|
||||
console.log(`бандл возвращён к закоммиченному: ${roots.join(', ')} (#657)`);
|
||||
return 0;
|
||||
}
|
||||
console.error('usage: bundle-policy.mjs --must-match [<ref>] | --verify [<ref>] | --clean');
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
|
||||
try {
|
||||
process.exitCode = main(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
console.error(`bundle-policy: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
+14
-10
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Бандл головы `dev` для стенда разработки — из артефакта Validate (#657).
|
||||
*
|
||||
* С #657 закоммиченный бандл меняется только релизным кандидатом, поэтому
|
||||
* дерево `dev` между бетами несёт бандл последней беты. Стенд
|
||||
* `dev.houseplan.tech` должен показывать голову `dev`, и его источник —
|
||||
* собранный Validate `dist/` того же SHA (артефакт `card-bundle`), а не
|
||||
* дерево.
|
||||
*
|
||||
* Скрипт публикует этот `dist/` в служебную ветку `dev-build` одним
|
||||
* коммитом без родителей: `custom_components/houseplan/frontend/**` плюс
|
||||
* `DEV-BUILD.json` с SHA источника. Ветка перезаписывается каждый раз —
|
||||
* истории в ней нет, и в историю `dev` бандл больше не попадает. HACS её
|
||||
* не видит: он ставит релизы (`zip_release`), не ветки.
|
||||
*
|
||||
* Хост стенда забирает её `demo/stand/update-dev-bundle.sh`.
|
||||
*
|
||||
* node scripts/dev-build.mjs --sha <sha> [--dist dist] [--remote origin]
|
||||
* [--branch dev-build] [--expect-ref refs/heads/dev] [--dry-run]
|
||||
*
|
||||
* Публикуется только голова: если `--expect-ref` на удалённом уже ушёл
|
||||
* вперёд, прогон ничего не пушит — следующий Validate опубликует свежее.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { cpSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { verifyBundleTree } from './bundle-tree.mjs';
|
||||
|
||||
export const DEV_BUILD_BRANCH = 'dev-build';
|
||||
export const DEV_BUILD_MARKER = 'DEV-BUILD.json';
|
||||
export const DEV_BUILD_TARGET = 'custom_components/houseplan/frontend';
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const value = (name, fallback = null) => {
|
||||
const eq = argv.find((arg) => arg.startsWith(`--${name}=`));
|
||||
if (eq) return eq.slice(name.length + 3);
|
||||
const at = argv.indexOf(`--${name}`);
|
||||
return at >= 0 && argv[at + 1] && !argv[at + 1].startsWith('--') ? argv[at + 1] : fallback;
|
||||
};
|
||||
return {
|
||||
sha: value('sha'),
|
||||
dist: value('dist', 'dist'),
|
||||
remote: value('remote', 'origin'),
|
||||
branch: value('branch', DEV_BUILD_BRANCH),
|
||||
expectRef: value('expect-ref', 'refs/heads/dev'),
|
||||
dryRun: argv.includes('--dry-run'),
|
||||
};
|
||||
}
|
||||
|
||||
function makeGit(cwd, extraEnv = {}) {
|
||||
return (args, { allowFailure = false } = {}) => {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: { ...process.env, ...extraEnv } });
|
||||
if (r.error) throw r.error;
|
||||
if (r.status !== 0 && !allowFailure) {
|
||||
throw new Error(`git ${args.join(' ')} → ${(r.stderr || r.stdout || '').trim()}`);
|
||||
}
|
||||
return { ok: r.status === 0, out: (r.stdout || '').trim() };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Собрать коммит `dev-build` в объектах репозитория `cwd`, не трогая его
|
||||
* рабочее дерево и индекс: отдельный временный индекс и отдельное дерево.
|
||||
*/
|
||||
export function buildDevBuildCommit({ cwd, dist, sha, now = new Date() }) {
|
||||
const manifest = verifyBundleTree(resolve(cwd, dist));
|
||||
const stage = mkdtempSync(join(tmpdir(), 'hp-dev-build-'));
|
||||
try {
|
||||
cpSync(resolve(cwd, dist), join(stage, DEV_BUILD_TARGET), { recursive: true });
|
||||
const marker = {
|
||||
schema: 1,
|
||||
source: sha,
|
||||
fingerprint: manifest.fingerprint ?? null,
|
||||
files: manifest.files.length,
|
||||
builtAt: now.toISOString(),
|
||||
};
|
||||
writeFileSync(join(stage, DEV_BUILD_MARKER), `${JSON.stringify(marker, null, 2)}\n`);
|
||||
const index = join(stage, '.git-index');
|
||||
const git = makeGit(cwd, { GIT_INDEX_FILE: index });
|
||||
git(['read-tree', '--empty']);
|
||||
git(['--work-tree', stage, 'add', '-A', '--', DEV_BUILD_TARGET, DEV_BUILD_MARKER]);
|
||||
const tree = git(['write-tree']).out;
|
||||
const commit = git([
|
||||
'-c', 'user.name=github-actions[bot]',
|
||||
'-c', 'user.email=41898282+github-actions[bot]@users.noreply.github.com',
|
||||
'commit-tree', tree, '-m', `dev-build: ${sha}\n\nSource: ${sha}\nIssue: #657\nUser-Visible: no`,
|
||||
]).out;
|
||||
return { commit, tree, marker };
|
||||
} finally {
|
||||
rmSync(stage, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Опубликовать, только если источник — всё ещё голова `expectRef`. */
|
||||
export function publishDevBuild({ cwd = process.cwd(), sha, dist = 'dist', remote = 'origin',
|
||||
branch = DEV_BUILD_BRANCH, expectRef = 'refs/heads/dev', dryRun = false, log = console.log } = {}) {
|
||||
if (!/^[0-9a-f]{40}$/.test(String(sha || ''))) throw new Error(`--sha must be a full commit SHA, got ${JSON.stringify(sha)}`);
|
||||
const git = makeGit(cwd);
|
||||
const head = git(['ls-remote', remote, expectRef]).out.split(/\s+/)[0] || '';
|
||||
if (head && head !== sha) {
|
||||
log(`${expectRef} на ${remote} уже ${head.slice(0, 8)}, а сборка — ${sha.slice(0, 8)}: публикует следующий прогон`);
|
||||
return { published: false, reason: 'stale' };
|
||||
}
|
||||
const built = buildDevBuildCommit({ cwd, dist, sha });
|
||||
if (dryRun) {
|
||||
log(`--dry-run: ${branch} ← ${built.commit.slice(0, 8)} (${built.marker.files} ассетов из ${sha.slice(0, 8)})`);
|
||||
return { published: false, reason: 'dry-run', ...built };
|
||||
}
|
||||
// Ветка без истории: каждый прогон заменяет её целиком (один коммит без родителя).
|
||||
git(['push', '--force', remote, `${built.commit}:refs/heads/${branch}`]);
|
||||
log(`${branch} ← ${built.commit.slice(0, 8)}: бандл ${sha.slice(0, 8)} (${built.marker.files} ассетов)`);
|
||||
return { published: true, ...built };
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
|
||||
try {
|
||||
publishDevBuild(parseArgs(process.argv.slice(2)));
|
||||
} catch (error) {
|
||||
console.error(`dev-build: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -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/.
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -38,9 +38,12 @@
|
||||
* исполняемым диффом, которая не HEAD, отклоняется — иначе зелёный вердикт был
|
||||
* бы вынесен чужому дереву; обход назван в сообщении.
|
||||
*
|
||||
* Бандл не собирается: `bundle-sync.mjs` раскладывает закоммиченный `dist`, а
|
||||
* Бандл не собирается: `bundle-sync.mjs` раскладывает `dist` рабочего дерева, а
|
||||
* свежесть проверяет сам продукт — `assertFreshDemoBundle` внутри каждого смока
|
||||
* сверяет вшитый отпечаток с исходниками дерева и скажет, если нужна пересборка.
|
||||
* С #657 закоммиченный `dist` законно отстаёт до кандидата беты: перед push
|
||||
* ветки, трогающей исходники, соберите `npm run build` (в коммит сборка не
|
||||
* идёт — `npm run bundle:clean`, если она попала в индекс).
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
|
||||
+15
-32
@@ -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]
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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- ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }); }
|
||||
});
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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: собрано, но не закоммичено');
|
||||
|
||||
@@ -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 }); }
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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'), '');
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
// Заглушка набора: код выхода и журнал вызовов задаёт тест.
|
||||
|
||||
@@ -214,8 +214,10 @@ test('#643 process.yml: шаг «Привести ветку к dev» ребей
|
||||
assert.match(step, /files=\$\(node "\$tools\/scripts\/rebase-generated\.mjs" --onto=origin\/dev\) \|\| code=\$\?/);
|
||||
assert.match(step, /if \[ "\$code" -ne 0 \] && \[ "\$code" -ne 3 \]; then/, 'сбой помощника — не конфликт');
|
||||
assert.match(step, /echo 'conflict=true'\n\s+echo 'conflicts<<EOF_FILES'/, 'выход conflict/conflicts на месте');
|
||||
// Порядок: ребейз → индекс свеж → push с lease → ожидание ссылки.
|
||||
const order = ['rebase-generated.mjs', '--commit-if-stale --issue="$NUM"', '--force-with-lease="refs/heads/$BRANCH:$before"', 'if [ "$seen" = "$after" ]; then settled=true'];
|
||||
// Порядок: ребейз → push с lease → ожидание ссылки. Индекс в ветке задачи
|
||||
// не пересобирается (#657, 1б): его догоняет слияние кандидата в dev.
|
||||
assert.doesNotMatch(step, /--commit-if-stale/);
|
||||
const order = ['rebase-generated.mjs', '--force-with-lease="refs/heads/$BRANCH:$before"', 'if [ "$seen" = "$after" ]; then settled=true'];
|
||||
const at = order.map((needle) => step.indexOf(needle));
|
||||
assert.ok(at.every((i) => i >= 0), JSON.stringify(at));
|
||||
assert.deepEqual([...at].sort((a, b) => a - b), at, 'порядок шагов сохранён');
|
||||
@@ -235,7 +237,7 @@ function runStepRebase(work) {
|
||||
const body = step.slice(step.indexOf(' run: |\n') + ' run: |\n'.length)
|
||||
.split('\n').map((line) => line.replace(/^ {10}/, '')).join('\n');
|
||||
const from = body.indexOf('tools="$RUNNER_TEMP/rebase-tools"');
|
||||
const to = body.indexOf('# #635 r2:');
|
||||
const to = body.indexOf('# #657 (1б): индекс ревью в ветке');
|
||||
assert.ok(from >= 0 && to > from, 'ребейзная часть шага найдена');
|
||||
const temp = mkdtempSync(join(tmpdir(), 'hp-runner-'));
|
||||
try {
|
||||
|
||||
+12
-14
@@ -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 }); }
|
||||
});
|
||||
|
||||
@@ -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'/);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user