Files
houseplan-card/docs/reviews/CODE-REVIEW-514-r3.md
T
2026-09-09 21:18:51 +00:00

31 KiB
Raw Blame History

CODE-REVIEW-514-r3

  • Issue: https://github.com/Matysh/houseplan-card/issues/514 — «E2E на реальном HA как гейт стабильного релиза»
  • ТЗ: docs/specs/514-e2e-stable-release-gate.md (зелёное ревью ТЗ, docs/reviews/SPEC-REVIEW-514-r1.md, r1, High 0/Medium 0)
  • Материал ревью: ровно d166bad3eb293d70cdd37cc715c639449742d988 (рабочая копия на нём). git log --oneline origin/dev..HEAD — 7 коммитов. git diff origin/dev...HEAD — 14 файлов, +824/−3.
  • Заход: r3 · блокирующих циклов израсходовано 0 из 4

Почему разбор полный, а не по дельте

r2 (комментарий issue, 2026-09-09T19:41:15Z) вынес зелёный вердикт на материале 539756cc, 0 High/0 Medium. Пока ревью r2 шло, dev продвинулся, ветка была ребейзнута на dev@6b592c13, кандидат стал 6d28e170 — этот кандидат ни разу не рецензировался (страж вернул задачу в S7-code-review автоматически до начала нового захода). Затем конвейер ещё раз привёл ветку к dev до того, как это ревью началось: поверх 6d28e170 легло 4 коммит(а) dev (докладки предыдущих задач #508/#502 и fix: the summary panel settings dialog scrolls in Home Assistant), дав текущий d166bad3. git merge-base 6d28e170 HEAD = 6b592c13 — 6d28e170 не является предком текущего HEAD, это осиротевший коммит другого дерева. Ребейз на ушедший вперёд dev — это другой код (§7.2), и инструкция к этому раунду прямо предписывает полный разбор. Прочитан и перепроверен весь diff origin/dev...HEAD заново, ни один вывод r2 не принят на слово.

Скоуп изменений

  1. scripts/e2e-gate.mjs (новый, 149 строк) — dispatch e2e.yml в houseplan-e2e на теге релиза, опознание своего прогона, ожидание, CLI.
  2. .github/workflows/release.yml, job gate — шаг «Require green E2E on a real Home Assistant for a stable release» после Full Performance, только для !prerelease.
  3. scripts/mutation-gate.mjs — три новых мутанта (release-ships-on-red-e2e, release-upgrades-stable-onto-itself, release-trusts-foreign-e2e-run).
  4. test/e2e-gate.test.mjs (12 тестов, новый), test/release-workflow.test.mjs (2 теста, новый).
  5. Документация: PROCESS.md, AGENTS.md, docs/DEVELOPMENT.md, docs/TESTING.md, docs/specs/README.md.
  6. docs/specs/514-e2e-stable-release-gate.md, docs/reviews/{SPEC,CODE}-REVIEW-514-r1.md, docs/reviews/CODE-REVIEW-514-r2.md — процессные артефакты, не код.

Файлы только класса B (scripts/**, test/**, .github/workflows/**) и C (docs/**); класс A (src/**, custom_components/**) не задет. Все 7 коммитов несут Issue: #514 / User-Visible: no — верно: изменение видно только владельцу в логе гейта релиза, ни один changelog трогать не нужно.

Как проверялось

Дешёвые гейты. Условие раунда называет Validate зелёным на точном d166bad3; проверено напрямую, не на слово:

gh api repos/Matysh/houseplan-card/actions/runs/34404724698 --jq '{head_sha,conclusion,event,status}'
→ {"conclusion":"success","event":"workflow_dispatch","head_sha":"d166bad3eb293d70cdd37cc715c639449742d988","status":"completed"}

head_sha совпадает с материалом ревью, conclusion: success — npx tsc --noEmit, npm test, npm run build с сверкой бандла не перегонялись целиком, как разрешает правило «дешёвые гейты уже подтверждены».

Прогнано лично на этом SHA (диф это код меняет, поэтому — то, что относится к диффу):

  • node --test test/e2e-gate.test.mjs test/release-workflow.test.mjs → # pass 14 # fail 0.
  • node scripts/mutation-gate.mjs --id=release-ships-on-red-e2e → «поймано 1 из 1».
  • node scripts/mutation-gate.mjs --id=release-upgrades-stable-onto-itself → «поймано 1 из 1».
  • node scripts/mutation-gate.mjs --id=release-trusts-foreign-e2e-run → «поймано 1 из 1».
  • node scripts/process-gate.mjs --issues → «гейт пройден, предупреждений 1» (единственное предупреждение — п.8, ожидаемо: инфраструктурный диапазон без файлов класса A, статусная метка issue не требуется).

Не прогонялось и почему (правило «объём гейтов соразмерен задаче»): golden:verify (diff не меняет рендер), check-docs.mjs/model-invariants.mjs/браузерные смоки (src/**, геометрия, layout не затронуты — вся правка в .github/workflows, scripts/e2e-gate.mjs, scripts/mutation-gate.mjs, тестах и docs/**), pytest tests_backend (Python не тронут), performance-профили (не названы в AC, перф-путь не тронут).

Внешний контракт (houseplan-e2e), не принятый на слово хендоффа/предыдущих раундов. scripts/e2e-gate.mjs целиком зависит от контракта имени job в чужом репозитории — перепроверено напрямую в этом раунде, не по цитате из r1/r2:

gh api repos/Matysh/houseplan-e2e/contents/.github/workflows/e2e.yml

Подтверждено построчно: job plan собирает матрицу node -e-скриптом; journeys-dev добавляется только if (schedule) (AC4 — на workflow_dispatch этой ветки в матрице нет); job e2e называется "${{ matrix.suite }} · HP ${{ matrix.ref }} · HA ${{ matrix.ha }}" — совпадает с TAG_SUITES/isOurRun/classifyRun в scripts/e2e-gate.mjs буква в букву; concurrency.cancel-in-progress: false — подтверждает трактовку cancelled как «отменено рукой», а не конкурентной отменой. Оба репозитория публичные (gh api repos/.../--jq .private → false), это существенно для находки M1 ниже.

Проверка AC (docs/specs/514-e2e-stable-release-gate.md §10)

AC Проверено Результат
AC1 (гейт держит ассеты до success; красный/missing/cancelled/error — с причиной и ссылкой) test/e2e-gate.test.mjs (7 тестов green/red/missing/timeout/cancelled/token-error), чтение e2eGate целиком, мутант release-ships-on-red-e2e доказан, кроме одного пути отказа токена — см. M1
AC2 (опознание своего прогона по HP <tag>, чужой dispatch игнорируется) тесты isOurRun/classifyRun, контракт имени job в houseplan-e2e/e2e.yml (сверен напрямую), мутант release-trusts-foreign-e2e-run доказан
AC3 (пре-релизы шаг не выполняют) test/release-workflow.test.mjs, прочитан release.yml:62 — if: ${{ !github.event.release.prerelease }}, идентично соседнему шагу Full Performance доказан
AC4 (journeys-dev не бежит на dispatch) прочитан houseplan-e2e/e2e.yml напрямую: if (schedule) include.push(...) — на workflow_dispatch schedule ложно доказан чтением
AC5 (все три мутанта пойманы) лично прогнаны все три — «поймано 1 из 1» каждый доказан
AC6 (документация §8, User-Visible: no, UX/i18n/модель/перф не затронуты) диф PROCESS.md/AGENTS.md/docs/DEVELOPMENT.md/docs/TESTING.md/docs/specs/README.md сверен построчно с §8 ТЗ; трейлеры всех 7 коммитов проверены git log --format доказан, с находкой L4 (формулировка)

Находки

M1 (Medium, в скоупе задачи) — фолбэк-токен E2E_DISPATCH_TOKEN, если он scoped только на houseplan-e2e, молча ломает previousStable и воспроизводит уже дважды исправленный дефект самообновления

release.yml использует один и тот же GH_TOKEN для всех вызовов gh внутри scripts/e2e-gate.mjs за один запуск процесса:

env:
  GH_TOKEN: ${{ secrets.E2E_DISPATCH_TOKEN || secrets.HP_PROCESS_TOKEN }}

Но e2eGate этим единственным токеном обращается к двум разным репозиториям:

  • ops.dispatch() / ops.listRuns() / ops.jobs() → Matysh/houseplan-e2e (нужен actions: write/read);
  • ops.releases() → Matysh/houseplan-card (тот самый репозиторий, где выполняется workflow) — вызывается первым, до dispatch, чтобы вычислить upgradeFrom (previousStable).

TOKEN_HINT и текст в ТЗ §4 п.1/§12 описывают E2E_DISPATCH_TOKEN как «секрет с правом Actions: write на houseplan-e2e» — то есть буквально тот сценарий, для которого владелец заведёт fine-grained PAT со списком репозиториев только houseplan-e2e (это единственный способ дать actions: write fine-grained токеном — вариант «Public repositories, read-only» прав на запуск workflow не даёт вообще). У такого токена нет доступа к houseplan-card, даже притом что оба репозитория публичные: fine-grained PAT с явным списком репозиториев не получает implicit-доступ к другим репозиториям только на основании их публичности — это документированное ограничение модели fine-grained-токенов, не зависящее от private/public.

Дальше цепочка молчит, а не падает явно:

// scripts/e2e-gate.mjs:117-119
releases: async () => parse(exec('gh', ['release', 'list', '--repo', cardRepo, ...])),
// scripts/e2e-gate.mjs:117 (helper)
const parse = (r) => (r.status === 0 && r.stdout ? JSON.parse(r.stdout) : []);

parse не бросает исключение на ненулевом коде выхода gh (в отличие от dispatch(), которая явно проверяет r.status !== 0 и кидает Error, — но эта явная проверка есть только у dispatch, не у releases/listRuns/jobs). При отказе (403 на houseplan-card) releases() вернёт [], previousStable([], tag) тихо даст фолбэк 'stable' (та самая ветка в коде: return prior[0]?.tagName || 'stable';), и dispatch(tag, 'stable') успешно уйдёт в houseplan-e2e (для этого вызова токен как раз имеет права) — с параметром upgrade_from=stable.

Это ровно дефект, который коммит 4143f998 в этой же ветке уже один раз исправлял по живому прогону 09.09 (houseplan-e2e run 34392391382): к моменту release: published тег уже сам является последним stable, upgrade_from=stable заставляет suite upgrade обновлять релиз сам на себя и падать с Expected: not "1.73.0". При срабатывании M1 гейт корректно покажет red (инвариант «не публиковать на красном» не нарушен — сборка не уйдёт пользователям), но диагноз в выводе гейта будет:

result=red
note=E2E на v1.75.0 завершился: failure

— неотличимо от обычного упавшего E2E-сценария, а не от токен-проблемы. Владелец, увидев красный upgrade с текстом Expected: not "1.75.0", с высокой вероятностью примет это за регрессию уже исправленного дефекта (4143f998/b36e19c1) и потратит время на разбор логики previousStable/isOurRun, хотя причина — токен без доступа к houseplan-card, которая нигде явно не всплывает (ни в выводе e2e-gate.mjs, ни в логе шага release.yml). Именно для этого класса отказов и была добавлена явная TOKEN_HINT — но она покрывает только ошибку в dispatch(), а не в releases().

Воспроизведение (логическое, по коду, не по живому прогону — секрет E2E_DISPATCH_TOKEN ещё не заведён владельцем):

  1. Владелец заводит E2E_DISPATCH_TOKEN как fine-grained PAT, «Only select repositories: houseplan-e2e», actions: write — ровно то, что просит TOKEN_HINT.
  2. release.yml подставляет его в GH_TOKEN для всего шага (E2E_DISPATCH_TOKEN непустой → используется он, не HP_PROCESS_TOKEN).
  3. ops.releases() → gh release list --repo Matysh/houseplan-card падает с 403, parse глотает ошибку, возвращает [].
  4. previousStable([], tag) → 'stable'.
  5. ops.dispatch(tag, 'stable') уходит успешно (у токена есть права на houseplan-e2e).
  6. Suite upgrade обновляет тег на самого себя, падает; гейт красный с обычным на вид текстом об упавшем E2E, без единого упоминания токена или houseplan-card.

Почему это Medium, а не Low, и не тот же класс, что уже принятые Low-находки r1/r2 (плановая job упала раньше первой именованной суб-job → missing). Там результат — менее точное, но честно неопределённое сообщение («не появился/не завершился»); здесь результат — правдоподобно замаскированный под уже однажды диагностированный и исправленный баг конкретный симптом (Expected: not "<tag>" в suite upgrade), что стоит владельцу реального времени на ложный след, причём именно в том месте, которое эта же задача дважды чинила по живым прогонам 09.09.

В скоупе задачи — оба места (scripts/e2e-gate.mjs, .github/workflows/release.yml) — центральные файлы этого диффа. Правка дешёвая: например, releases() должна либо бросать (throw) на r.status !== 0, как это уже делает dispatch(), чтобы ошибка попала в тот же catch блок e2eGate и вернула result: 'error' с текстом про токен, либо — чище — вызов gh release list --repo Matysh/houseplan-card должен идти под github.token (у него всегда есть доступ на чтение к репозиторию, где выполняется сам workflow), а E2E_DISPATCH_TOKEN/HP_PROCESS_TOKEN — только под вызовы в сторону houseplan-e2e. Не блокирует сегодняшнее поведение (сейчас E2E_DISPATCH_TOKEN не существует, HP_PROCESS_TOKEN имеет широкий repo-scope и покрывает оба репозитория) — риск реализуется только если/когда владелец заведёт секрет ровно так, как просит TOKEN_HINT.

Low (приняты без правки, с записью)

  • L1 (унаследовано из r1/r2, перепроверено чтением заново на d166bad3). Если job plan в houseplan-e2e/e2e.yml сама завершится failure раньше, чем появится хотя бы одна job вида · HP … ·, classifyRun вернёт 'unknown', а поскольку прогон уже completed, ветка if (kind === 'foreign' || candidate.status === 'completed') foreign.add(...) в e2eGate навсегда пометит его как чужой — результат missing вместо точной причины. Инвариант «не публиковать на красном» не нарушается. Подтверждено чтением scripts/e2e-gate.mjs:83-97 заново в этом раунде — код не изменился с r1/r2.
  • L2 (унаследовано из r2). README houseplan-e2e может по-прежнему буквально описывать upgrade_from=stable, хотя с 4143f998 гейт вычисляет previousStable(). Файл вне материала этого ревью (другой репозиторий, свой процесс туда не распространяется); поведение самого гейта верно и покрыто тестом. Не проверялось заново построчно в этом раунде (правка README в houseplan-e2e не входит в origin/dev...HEAD), доверие к выводу r2 обосновано — контракт имени job из того же файла перепроверен напрямую (см. «Как проверялось»), а раздел README — чисто описательный текст, не код, от которого что-то зависит.
  • L3 (унаследовано из r1/r2). Окно gh run list --limit 10: если за время ожидания в houseplan-e2e случится ≥10 параллельных чужих workflow_dispatch прогонов e2e.yml, уже отслеживаемый tracked может выпасть из выдачи. Паттерн взят из validate-gate.mjs (принятый образец, #510), реалистичность — только ручной параллельный dispatch owner'ом много раз подряд.
  • L4 (новое). PROCESS.md:704-706: «плюс зелёный E2E на реальном Home Assistant на теге (houseplan-e2e, запускает и ждёт release.yml, #514)» — подлежащее и дополнение перепутаны местами: это release.yml запускает и ждёт houseplan-e2e/e2e.yml, а не наоборот. Чисто формулировка, поведение и код не описывают неверно ничего исполняемого; не блокирует.

High не найдено. Medium вне скоупа не найдено — единственный Medium (M1) целиком внутри файлов, которые эта задача и меняет.

Что проверено и корректно

  • e2eGate — вся логика green/red/missing/cancelled/error (кроме диагностики M1) прочитана и подтверждена тестами и тремя мутантами.
  • previousStable — фильтрует draft/prerelease/сам тег, берёт первый оставшийся; порядок gh release list (новые первыми) не решоверен заново в этом раунде отдельным gh api-вызовом, но код идентичен r1/r2, где порядок был подтверждён напрямую, и с тех пор не менялся — переносится без риска.
  • isOurRun/classifyRun — корректно отличают journeys/first-run (несут тег под тестом) от upgrade (несёт тег предыдущего stable), не путают vX.Y.Z с vX.Y.Z-beta.N (тест).
  • release.yml — новый шаг вставлен строго после Full Performance, строго для !prerelease, needs: gate не даёт build выполниться при падении шага.
  • scripts/mutation-gate.mjs — три мутанта реально снимают заявленную защиту, а не косметику; все три поймало штатным раннером.
  • Контракт имени job в houseplan-e2e/e2e.yml, от которого зависит вся идентификация «своего» прогона, подтверждён прямым чтением файла в этом раунде, а не унаследован из r1/r2 без проверки.
  • Документация (PROCESS.md, AGENTS.md, docs/DEVELOPMENT.md, docs/TESTING.md, docs/specs/README.md) обновлена консистентно с реализацией и §8 ТЗ (с оговоркой L4).
  • Трейлеры всех 7 коммитов (Issue: #514, User-Visible: no) корректны; User-Visible: no верно — изменение видно только владельцу.
  • Диапазон изменений строго в рамках скоупа ТЗ (§2); попутных правок «раз уж я здесь» не обнаружено.
  • Одно число / один источник — неприменимо: диф не добавляет и не меняет ни одной пользователем видимой величины.

Чего не проверял и почему

  • tsc --noEmit, npm test (полный), npm run build с попиксельной сверкой бандла — зелёный Validate на точном d166bad3 уже это покрывает (проверено через gh api, head_sha совпадает).
  • golden:verify, check-docs.mjs, model-invariants.mjs, pytest tests_backend, браузерные смоки — diff не трогает src/**, геометрию/layout/толщины или Python.
  • Реальный dispatch release.yml по событию release: published и фактическую работу HP_PROCESS_TOKEN на workflow_dispatch в houseplan-e2e — недоступно ревьюеру (нет доступа к секретам владельца, событие release нельзя сэмулировать без публикации настоящего релиза). Первая живая проверка — следующий stable, как честно называет ТЗ; путь отказа сейчас покрыт тестом только для ошибки dispatch(), не для ошибки releases() — это и есть M1.
  • README houseplan-e2e построчно в этом раунде — вне диапазона origin/dev...HEAD, контракт имени job (единственное, от чего зависит код) перепроверен напрямую отдельно.
  • Полный список demo/smoke_*.mjs не запрашивал и smoke-select.mjs не гонял — diff не содержит исполняемого frontend-кода (src/** не тронут), выбирать нечего механически, без инструмента.

Продуктовое рассуждение

Задача не закрывает напрямую ни одну строку docs/SCOPE.md — это инфраструктура процесса релиза, а не поверхность продукта. Она укрепляет цепочку, которой продукт уже поставляется в HACS: houseplan-e2e существовал и был зелёным по ночам, но stable-релиз уходил, не дождавшись его результата на самом тестируемом теге. После этой правки — дождётся. Согласуется с решением владельца из тела issue («E2E — гейт стабильного релиза») без расширения скоупа и без ухудшения соседнего поведения (беты не тронуты — подтверждено условием if и тестом AC3). M1 не меняет этой оценки: сегодняшнее поведение безопасно, риск — в будущей конфигурации токена, которую сам код и подсказывает завести именно так, что и ломает releases().

Вопросы владельцу

Нет открытых продуктовых вопросов. M1 — техническая находка ревью, не продуктовый вопрос: решается автором в реализации (два равноценных пути указаны выше), владелец эту развилку решать не должен.

Закрытие раунда r2

Находка r2 Чем закрыта Где это видно
L1 (плановая job упадёт раньше первой именованной суб-job → missing) Не закрыта, не требовалась к закрытию — Low принят без правки в r2, поведение не изменилось scripts/e2e-gate.mjs:83-97 идентичен коду, прочитанному в r2; переносится как L1 этого документа
L2 (README houseplan-e2e описывает upgrade_from=stable буквально) Не закрыта, не требовалась — файл вне материала процесса houseplan-card переносится как L2 этого документа
L3 (окно gh run list --limit 10) Не закрыта, не требовалась — принятый паттерн из validate-gate.mjs переносится как L3 этого документа

r2 не содержал High/Medium — закрывать нечего, кроме подтверждения, что три Low не выросли в Medium на текущем коде (подтверждено чтением заново, код не изменился между 539756cc и d166bad3 в файлах scripts/e2e-gate.mjs/scripts/mutation-gate.mjs).

Унаследовано из r2

Формально ничего не наследуется без повторной проверки: ребейз на ушедший вперёд dev (§7.2) — явно исключённый случай для сокращённого делта-разбора, весь origin/dev...HEAD на d166bad3 прочитан заново в этом раунде. Единственное, что не перепроверялось отдельным вызовом в r3 (но перепроверялось чтением кода, не изменившегося с r2) — порядок gh release list --repo Matysh/houseplan-card (новые релизы первыми), на котором стоит previousStable: подтверждён прямым вызовом в r1 и r2 (docs/reviews/CODE-REVIEW-514-r2.md, SHA 539756cc), код previousStable идентичен байт-в-байт текущему. Содержимое README houseplan-e2e (L2) — тоже не перечитывалось построчно в r3, доверие к выводу r2 обосновано тем, что это внешний, неисполняемый для этого репозитория файл вне диапазона origin/dev...HEAD.


Материал раунда

  • Ветка: issue/514-e2e-stable-release-gate, коммит d166bad3eb293d70cdd37cc715c639449742d988.
  • Диапазон: origin/dev..HEAD (7 коммитов), origin/dev = 3c1b4ebee2b60c7cc9ea5d9021046b2da1ba5832.
  • ТЗ: docs/specs/514-e2e-stable-release-gate.md; ревью ТЗ: docs/reviews/SPEC-REVIEW-514-r1.md (зелёное, r1).
  • Предыдущий код-ревью: r2, зелёный, материал 539756cc000cd39c6e9cf8e1fda9ffe0824d46fd (осиротел после повторного ребейза).
  • Validate на материале раунда: https://github.com/Matysh/houseplan-card/actions/runs/34404724698 (success, head_sha совпадает).
  • Вердикт этого раунда: жёлтый · High 0 · Medium 1 (M1, в скоупе) · Low 4 (L1-L3 унаследованы, L4 новая).

Материал раунда

  • Ветка: issue/514-e2e-stable-release-gate, коммит 6d28e170913c — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
  • Дерево материала: 260cde45610a89e6a11ac3ad39794ecfffe98351
    git log --all --format='%H %T' | grep 260cde45610a
    
  • ТЗ docs/specs/514-e2e-stable-release-gate.md, блоб 30795778fee11c8df610440dd86b745112e120c4
    git log --all --find-object=30795778fee11c8df610440dd86b745112e120c4 -- docs/specs/514-e2e-stable-release-gate.md
    
  • Вердикт конвейера: yellow · High 0