31 KiB
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 не принят на слово.
Скоуп изменений
scripts/e2e-gate.mjs(новый, 149 строк) — dispatche2e.ymlвhouseplan-e2eна теге релиза, опознание своего прогона, ожидание, CLI..github/workflows/release.yml, jobgate— шаг «Require green E2E on a real Home Assistant for a stable release» после Full Performance, только для!prerelease.scripts/mutation-gate.mjs— три новых мутанта (release-ships-on-red-e2e,release-upgrades-stable-onto-itself,release-trusts-foreign-e2e-run).test/e2e-gate.test.mjs(12 тестов, новый),test/release-workflow.test.mjs(2 теста, новый).- Документация:
PROCESS.md,AGENTS.md,docs/DEVELOPMENT.md,docs/TESTING.md,docs/specs/README.md. 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 ещё не заведён владельцем):
- Владелец заводит
E2E_DISPATCH_TOKENкак fine-grained PAT, «Only select repositories: houseplan-e2e»,actions: write— ровно то, что проситTOKEN_HINT. release.ymlподставляет его вGH_TOKENдля всего шага (E2E_DISPATCH_TOKEN непустой → используется он, неHP_PROCESS_TOKEN).ops.releases()→gh release list --repo Matysh/houseplan-cardпадает с 403,parseглотает ошибку, возвращает[].previousStable([], tag)→'stable'.ops.dispatch(tag, 'stable')уходит успешно (у токена есть права наhouseplan-e2e).- 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). Если jobplanв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— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
260cde45610a89e6a11ac3ad39794ecfffe98351git log --all --format='%H %T' | grep 260cde45610a - ТЗ
docs/specs/514-e2e-stable-release-gate.md, блоб30795778fee11c8df610440dd86b745112e120c4git log --all --find-object=30795778fee11c8df610440dd86b745112e120c4 -- docs/specs/514-e2e-stable-release-gate.md - Вердикт конвейера:
yellow· High 0