mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
r6 Medium: AC4 was measurable only on a developer's machine — no workflow invoked mypy, so a typing regression in any of the six allowlist modules reached dev unnoticed while the issue claimed measurable backend quality. Coverage and lint had continuous gates; typing had a text comparison of a committed list. The backend job now runs mypy right after ruff, from the same pinned dependency file (mypy==2.3.1 — an unpinned checker would redden on code that never changed). The step derives its module list from the pyproject.toml strict allowlist instead of duplicating it, because a drifted duplicate is a green step checking the wrong modules, and it refuses an empty list rather than passing silently. Guarded twice: a contract test pins all three facts (pinned checker, a step that really invokes it, list read from pyproject) and the new typing-gate-stops-running mutant reddens when the invocation is neutered. User-Visible: no Issue: #42
845 lines
49 KiB
YAML
845 lines
49 KiB
YAML
name: Проверка (CI)
|
||
|
||
on:
|
||
push:
|
||
# The branch commit is the release-gate authority. An annotated tag points
|
||
# to the same SHA and must not duplicate the browser validation jobs.
|
||
branches:
|
||
- '**'
|
||
# Документы ревью конвейер пишет пачками — 340 коммитов за месяц, и каждый
|
||
# гонял лёгкую половину Validate впустую (≈15 часов раннера в месяц).
|
||
# Релизного кандидата это не затрагивает: тег всегда стоит на коммите,
|
||
# который меняет версию и бандл, а не только `docs/reviews/**`.
|
||
paths-ignore:
|
||
- 'docs/reviews/**'
|
||
pull_request:
|
||
|
||
# A new push supersedes an unfinished validation for the same branch or PR.
|
||
# Exact-SHA release gates never depend on an obsolete commit.
|
||
concurrency:
|
||
group: validate-${{ github.event.pull_request.number || github.ref }}
|
||
cancel-in-progress: true
|
||
|
||
jobs:
|
||
# Четыре секундных скрипта жили четырьмя job, каждая со своим checkout и
|
||
# setup-node ради нескольких секунд работы (#336). Здесь они шаги одной job.
|
||
#
|
||
# Независимость при этом сохранена намеренно: у каждого шага
|
||
# `continue-on-error`, поэтому упавший провенанс не прячет результат
|
||
# процессного гейта, а вердикт в конце падает, если упал хоть один. Прежняя
|
||
# запись «job независимый: краснеет сам и не роняет остальные» продолжает
|
||
# действовать — на уровне шагов, с той же гранулярностью в логе.
|
||
preflight:
|
||
name: "Предполётные проверки: документация, провенанс, процесс"
|
||
runs-on: ubuntu-latest
|
||
# `actions: read` — чтение списка прогонов Validate (#388), `issues: read` —
|
||
# проверка 8 процессного гейта. Права перечислены явно, потому что job
|
||
# обращается к API сверх содержимого репозитория.
|
||
permissions:
|
||
contents: read
|
||
actions: read
|
||
issues: read
|
||
steps:
|
||
# `blob:none` при полной истории (#345): этой job нужны сообщения
|
||
# коммитов, трейлеры и ИМЕНА изменённых файлов, а не содержимое старых
|
||
# ревизий. Полный клон стоит 215 МБ против 26 МБ, и 32% этого веса —
|
||
# скриншоты документации, переснятые 196 раз.
|
||
#
|
||
# Коммиты и деревья скачиваются целиком, поэтому диапазоны и `merge-base`
|
||
# работают как раньше. Единственная догрузка по требованию здесь —
|
||
# `git show origin/main:.github/workflows/process.yml`: один блоб.
|
||
- uses: actions/checkout@v7
|
||
with: { fetch-depth: 0, filter: 'blob:none' }
|
||
- uses: actions/setup-node@v7
|
||
with: { node-version: 22 }
|
||
|
||
- name: "Документация: гайды, ченджлоги, скриншот-индекс"
|
||
id: docs
|
||
continue-on-error: true
|
||
run: node scripts/check-docs.mjs --external
|
||
|
||
# Конвейер читает `process.yml` из ветки по умолчанию, поэтому файл обязан
|
||
# совпадать в `main` и `dev`. До этой проверки совпадение держалось на
|
||
# дисциплине: каждая правка требовала двух пушей и ручной сверки.
|
||
- name: "Процесс: process.yml идентичен в main и dev"
|
||
id: workflow_sync
|
||
continue-on-error: true
|
||
run: |
|
||
git fetch --quiet origin main dev
|
||
if diff <(git show origin/main:.github/workflows/process.yml) \
|
||
<(git show origin/dev:.github/workflows/process.yml); then
|
||
echo "main и dev идентичны"
|
||
else
|
||
echo "РАСХОЖДЕНИЕ: process.yml в main и dev различаются."
|
||
echo "Конвейер исполняет версию из ветки по умолчанию, поэтому"
|
||
echo "правку нужно отправить в обе ветки."
|
||
exit 1
|
||
fi
|
||
|
||
# Оба гейта ниже судят САМ диапазон коммитов, а не объём проверок, и до
|
||
# #388 брали его от головы предыдущего пуша. Прогон предыдущего пуша
|
||
# штатно отменяется следующим (concurrency), и тогда его коммиты не судит
|
||
# никто: свой прогон отменён, а следующий пуш сравнивает уже с ними.
|
||
#
|
||
# База — последний предок с УСПЕШНО завершённым Validate. Фолбэк остаётся
|
||
# прежним `before`: расширять диапазон здесь нельзя, иначе гейт, который
|
||
# сам красит прогон, лишает следующий пуш зелёного предка и запирает dev
|
||
# в красноте навсегда.
|
||
- name: "База диапазона: последний доказанно зелёный предок"
|
||
id: range
|
||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||
env:
|
||
GH_TOKEN: ${{ github.token }}
|
||
HEAD_SHA: ${{ github.sha }}
|
||
REPO: ${{ github.repository }}
|
||
FALLBACK: ${{ github.event.before }}
|
||
run: |
|
||
# `status=completed`, а не `success`: гейтам диапазона нужен факт
|
||
# «коммит судили», а не «вердикт был оправдательный». Упавший прогон
|
||
# коммит судил; отменённый — нет, его отсеивает judgedShas (#388).
|
||
gh api -X GET "repos/$REPO/actions/workflows/validate.yml/runs" \
|
||
-f branch=dev -f status=completed -F per_page=100 \
|
||
> /tmp/validate-runs.json || echo '{}' > /tmp/validate-runs.json
|
||
node scripts/classify-base.mjs --head="$HEAD_SHA" --mode=range \
|
||
--fallback="$FALLBACK" --runs=/tmp/validate-runs.json
|
||
- name: "Провенанс коммитов: трейлеры и эталоны"
|
||
id: provenance
|
||
continue-on-error: true
|
||
env:
|
||
EVENT_NAME: ${{ github.event_name }}
|
||
BEFORE_SHA: ${{ steps.range.outputs.base || github.event.before }}
|
||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||
HEAD_SHA: ${{ github.sha }}
|
||
DEVELOPMENT_BRANCH: dev
|
||
run: |
|
||
git fetch -q origin "refs/heads/$DEVELOPMENT_BRANCH:refs/remotes/origin/$DEVELOPMENT_BRANCH"
|
||
node scripts/validate-commit-provenance.mjs --check-hook-mode --github-range
|
||
|
||
# Догоняющая проверка процесса (PROCESS.md §10.3). Хуки ловят нарушение на
|
||
# машине автора, но их можно обойти `--no-verify`, а коммиты идут прямо в
|
||
# dev без PR — GitHub на своей стороне не блокирует ничего. Это последнее
|
||
# место, где нарушение правила №1 ловится машиной. Откат — удалить шаг,
|
||
# скрипт остаётся рабочим.
|
||
- name: "Процессный гейт: диапазон, трейлеры, статусы issue"
|
||
id: process_gate
|
||
continue-on-error: true
|
||
env:
|
||
EVENT_NAME: ${{ github.event_name }}
|
||
BEFORE_SHA: ${{ steps.range.outputs.base || github.event.before }}
|
||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||
HEAD_SHA: ${{ github.sha }}
|
||
DEVELOPMENT_BRANCH: dev
|
||
TARGET_REF: ${{ github.ref }}
|
||
# Публичный репозиторий: штатного токена хватает на чтение issue.
|
||
GH_TOKEN: ${{ github.token }}
|
||
run: |
|
||
git fetch -q origin "refs/heads/$DEVELOPMENT_BRANCH:refs/remotes/origin/$DEVELOPMENT_BRANCH"
|
||
node scripts/process-gate.mjs --github-range --issues
|
||
|
||
# Вердикт обязан быть громким и перечислять всё упавшее сразу: иначе
|
||
# слияние job стоило бы автору лишнего круга «починил одно — узнал о
|
||
# втором».
|
||
- name: Вердикт предполётных проверок
|
||
env:
|
||
DOCS: ${{ steps.docs.outcome }}
|
||
WORKFLOW_SYNC: ${{ steps.workflow_sync.outcome }}
|
||
PROVENANCE: ${{ steps.provenance.outcome }}
|
||
PROCESS_GATE: ${{ steps.process_gate.outcome }}
|
||
run: |
|
||
fail=0
|
||
check() {
|
||
if [ "$2" = "success" ]; then
|
||
echo "ok $1"
|
||
else
|
||
echo "FAIL $1 ($2)"
|
||
echo "- **$1**: $2" >> "$GITHUB_STEP_SUMMARY"
|
||
fail=1
|
||
fi
|
||
}
|
||
echo "### Предполётные проверки" >> "$GITHUB_STEP_SUMMARY"
|
||
check "документация" "$DOCS"
|
||
check "process.yml в main и dev" "$WORKFLOW_SYNC"
|
||
check "провенанс коммитов" "$PROVENANCE"
|
||
check "процессный гейт" "$PROCESS_GATE"
|
||
exit $fail
|
||
|
||
# Классификация изменённых путей: тяжёлые job идут только там, где менялось
|
||
# относящееся к ним. НА DEV ФИЛЬТРОВ НЕТ: гейт беты принимает «зелёный Validate
|
||
# на точном SHA», и если объём прогона зависит от diff, «зелёный» перестаёт
|
||
# значить одно и то же — кандидат релиза (манифесты + changelog) пропустил бы
|
||
# браузерные тесты, а прогон с пропущенными job всё равно success. Фильтры
|
||
# экономят на ветках задач, где Validate — ранний сигнал: настоящую приёмку
|
||
# там делает код-ревью, которое гоняет гейты само (#127).
|
||
changes:
|
||
name: Классификация изменённых файлов
|
||
runs-on: ubuntu-latest
|
||
# Чтение списка прогонов Validate — единственное, что этой job нужно сверх
|
||
# содержимого репозитория (#387).
|
||
permissions:
|
||
contents: read
|
||
actions: read
|
||
outputs:
|
||
frontend: ${{ steps.classify.outputs.frontend }}
|
||
backend: ${{ steps.classify.outputs.backend }}
|
||
integration: ${{ steps.classify.outputs.integration }}
|
||
base: ${{ steps.base.outputs.base }}
|
||
# Разные вещи под разными именами намеренно: `base` — до какого коммита
|
||
# классифицировать файлы ветки (#387), `range_base` — от какого коммита
|
||
# судить сам диапазон на dev (#388). Общее имя означало бы, что
|
||
# потребитель однажды молча возьмёт чужую базу.
|
||
range_base: ${{ steps.base.outputs.range_base }}
|
||
steps:
|
||
# `git diff --name-only` содержимого файлов не читает вовсе, поэтому
|
||
# блобы истории этой job не нужны ни на одном шаге (#345).
|
||
- uses: actions/checkout@v7
|
||
with: { fetch-depth: 0, filter: 'blob:none' }
|
||
# База диапазона — самый новый предок с УСПЕШНО завершённым Validate
|
||
# (#387). Прежде бралась голова предыдущего пуша (`github.event.before`),
|
||
# то есть допущение «до этого всё проверено». Concurrency отменяет прогон
|
||
# предыдущего пуша штатно, и на #86 (r5) это дало зелёный статус ветки
|
||
# без единого исполненного тяжёлого гейта.
|
||
- id: base
|
||
if: github.event_name != 'pull_request'
|
||
env:
|
||
GH_TOKEN: ${{ github.token }}
|
||
HEAD_SHA: ${{ github.sha }}
|
||
BRANCH: ${{ github.ref_name }}
|
||
REPO: ${{ github.repository }}
|
||
REF: ${{ github.ref }}
|
||
FALLBACK: ${{ github.event.before }}
|
||
run: |
|
||
git fetch -q origin dev
|
||
# Недоступность API — не отказ гейта: пустой ответ уводит базу в
|
||
# сторону БОЛЬШЕГО объёма проверок, а не меньшего.
|
||
# Один запрос на оба режима: `completed` — надмножество `success`,
|
||
# а нужный предикат применяет скрипт (#387 — успех, #388 — судимость).
|
||
gh api -X GET "repos/$REPO/actions/workflows/validate.yml/runs" \
|
||
-f branch="$BRANCH" -f status=completed -F per_page=100 \
|
||
> /tmp/validate-runs.json || echo '{}' > /tmp/validate-runs.json
|
||
if [ "$REF" = "refs/heads/dev" ]; then
|
||
# На dev классифицировать нечего (всё true), но база диапазона
|
||
# нужна гейту «новый код не добавляет any» в job frontend (#388).
|
||
node scripts/classify-base.mjs --head="$HEAD_SHA" --mode=range \
|
||
--name=range_base --fallback="$FALLBACK" --runs=/tmp/validate-runs.json
|
||
exit 0
|
||
fi
|
||
merge_base=$(git merge-base origin/dev "$HEAD_SHA" || git rev-parse "$HEAD_SHA~1")
|
||
node scripts/classify-base.mjs --head="$HEAD_SHA" \
|
||
--merge-base="$merge_base" --runs=/tmp/validate-runs.json
|
||
- id: classify
|
||
env:
|
||
EVENT_NAME: ${{ github.event_name }}
|
||
BEFORE_SHA: ${{ github.event.before }}
|
||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||
GREEN_BASE: ${{ steps.base.outputs.base }}
|
||
HEAD_SHA: ${{ github.sha }}
|
||
REF: ${{ github.ref }}
|
||
run: |
|
||
if [ "$REF" = "refs/heads/dev" ]; then
|
||
echo "dev: без фильтров, всё true"
|
||
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
|
||
exit 0
|
||
fi
|
||
zero=$(printf '%040d' 0)
|
||
# #347: непустой before, которого больше не существует, — это
|
||
# force-push (ребейз/аменд переписал историю ветки). Диапазон в этом
|
||
# случае НЕ угадывается: merge-base после переписанной истории уже
|
||
# выдавал «изменились только два doc-файла» при реальном диффе в
|
||
# custom_components/**, и тяжёлые job тихо скипались, а общий статус
|
||
# рана оставался success — ровно класс тихого пропуска #171/#207.
|
||
# Force-push редок, полный прогон дешевле ложного «зелёного».
|
||
#
|
||
# Проверка сохранена как есть и после #387: там база стала доказуемой
|
||
# (зелёный предок), но механизм, из-за которого merge-base врал на
|
||
# переписанной истории, не разобран до конца. Снимать защиту, не
|
||
# объяснив её, — способ получить #347 второй раз.
|
||
if [ -n "$BEFORE_SHA" ] && [ "$BEFORE_SHA" != "$zero" ] \
|
||
&& ! git cat-file -e "$BEFORE_SHA" 2>/dev/null; then
|
||
echo "force-push: before=$BEFORE_SHA переписан — полный прогон без классификации"
|
||
{
|
||
echo "### Классификация изменённых файлов"
|
||
echo "\`before\` (=$BEFORE_SHA) переписан force-push'ем: диапазон недоказуем,"
|
||
echo "все тяжёлые job запускаются без фильтра путей (#347)."
|
||
} >> "$GITHUB_STEP_SUMMARY"
|
||
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
|
||
exit 0
|
||
fi
|
||
# Диапазон считается от последнего ДОКАЗАННО зелёного предка (#387),
|
||
# а не от головы предыдущего пуша: его прогон штатно отменяется
|
||
# следующим пушем. PR — исключение: там база задана событием.
|
||
base="$GREEN_BASE"
|
||
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
|
||
if [ -z "$base" ]; then
|
||
echo "база не определена — полный прогон без классификации"
|
||
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
|
||
exit 0
|
||
fi
|
||
files=$(git diff --name-only "$base" "$HEAD_SHA")
|
||
printf '%s\n' "$files" | head -50
|
||
has() { printf '%s\n' "$files" | grep -qE "$1" && echo true || echo false; }
|
||
{
|
||
echo "frontend=$(has '^(src/|demo/|test/|dist/|custom_components/houseplan/frontend/|package(-lock)?\.json$|rollup\.config\.mjs$|tsconfig)')"
|
||
echo "backend=$(has '^(custom_components/.*\.py$|tests_backend/|pytest\.ini$)')"
|
||
echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')"
|
||
} >> "$GITHUB_OUTPUT"
|
||
|
||
# Переиспользование результата тяжёлой job (#208). Ключ = входы поведения
|
||
# (sourceFingerprint: src/**, demo/fixtures, demo/golden/*.mjs, манифесты
|
||
# сборки) ПЛЮС оснастка именно этой job. Маркер в кэше пишет только успешный
|
||
# прогон с тем же ключом, поэтому попадание доказывает: job с побайтово теми
|
||
# же входами уже завершилась успешно.
|
||
#
|
||
# Это НЕ фильтр путей из job `changes` (на dev они отключены намеренно): там
|
||
# объём прогона угадывается по путям и «зелёный» начинает значить разное,
|
||
# здесь эквивалентность входов доказана хешем.
|
||
#
|
||
# Свойство, снимающее главный риск: релизный кандидат бампает версию, а
|
||
# CARD_VERSION и package.json входят в фингерпринт, поэтому ключи кандидата
|
||
# заведомо новые и полный набор гейтов перед бетой и релизом идёт всегда.
|
||
reuse:
|
||
name: "Переиспользование: это дерево уже проверено"
|
||
runs-on: ubuntu-latest
|
||
outputs:
|
||
smoke: ${{ steps.probe.outputs.smoke }}
|
||
golden: ${{ steps.probe.outputs.golden }}
|
||
performance_smoke: ${{ steps.probe.outputs.performance_smoke }}
|
||
backend: ${{ steps.probe.outputs.backend }}
|
||
smoke_key: ${{ steps.keys.outputs.smoke }}
|
||
golden_key: ${{ steps.keys.outputs.golden }}
|
||
performance_smoke_key: ${{ steps.keys.outputs.performance_smoke }}
|
||
backend_key: ${{ steps.keys.outputs.backend }}
|
||
steps:
|
||
- uses: actions/checkout@v7
|
||
- uses: actions/setup-node@v7
|
||
with: { node-version: 22 }
|
||
- name: Ключи переиспользования
|
||
id: keys
|
||
run: |
|
||
for job in smoke golden performance_smoke backend; do
|
||
key=$(node scripts/gate-reuse.mjs --job="$job")
|
||
echo "$job=$key" >> "$GITHUB_OUTPUT"
|
||
echo "$job: $key"
|
||
done
|
||
# lookup-only: маркер только проверяется, но не восстанавливается —
|
||
# сохранять его в этой job нечего, она ничего не прогоняла.
|
||
- name: Маркер smoke
|
||
id: m_smoke
|
||
uses: actions/cache/restore@v6
|
||
with:
|
||
path: .reuse-marker
|
||
key: reuse-smoke-${{ steps.keys.outputs.smoke }}
|
||
lookup-only: true
|
||
- name: Маркер golden
|
||
id: m_golden
|
||
uses: actions/cache/restore@v6
|
||
with:
|
||
path: .reuse-marker
|
||
key: reuse-golden-${{ steps.keys.outputs.golden }}
|
||
lookup-only: true
|
||
- name: Маркер performance_smoke
|
||
id: m_perf
|
||
uses: actions/cache/restore@v6
|
||
with:
|
||
path: .reuse-marker
|
||
key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}
|
||
lookup-only: true
|
||
- name: Маркер backend
|
||
id: m_backend
|
||
uses: actions/cache/restore@v6
|
||
with:
|
||
path: .reuse-marker
|
||
key: reuse-backend-${{ steps.keys.outputs.backend }}
|
||
lookup-only: true
|
||
- name: Что переиспользуем
|
||
id: probe
|
||
env:
|
||
SMOKE: ${{ steps.m_smoke.outputs.cache-hit }}
|
||
GOLDEN: ${{ steps.m_golden.outputs.cache-hit }}
|
||
PERF: ${{ steps.m_perf.outputs.cache-hit }}
|
||
BACKEND: ${{ steps.m_backend.outputs.cache-hit }}
|
||
run: |
|
||
# Пропуск обязан быть громким: молчаливый skip — тот самый тихий
|
||
# успех, который уже дважды стоил нам дня (#171, #207).
|
||
waive() {
|
||
if [ "$2" = "true" ]; then
|
||
echo "$1=true" >> "$GITHUB_OUTPUT"
|
||
echo "::notice::$1 не прогоняется: входы побайтово те же, что в предыдущем успешном прогоне (#208)"
|
||
echo "- **$1** переиспользована: входы не менялись" >> "$GITHUB_STEP_SUMMARY"
|
||
else
|
||
echo "$1=false" >> "$GITHUB_OUTPUT"
|
||
echo "- $1: прогоняется" >> "$GITHUB_STEP_SUMMARY"
|
||
fi
|
||
}
|
||
echo "### Переиспользование гейтов (#208)" >> "$GITHUB_STEP_SUMMARY"
|
||
waive smoke "$SMOKE"
|
||
waive golden "$GOLDEN"
|
||
waive performance_smoke "$PERF"
|
||
waive backend "$BACKEND"
|
||
|
||
hacs:
|
||
name: "HACS: валидация репозитория"
|
||
needs: changes
|
||
if: needs.changes.outputs.integration == 'true'
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v7
|
||
- name: HACS validation
|
||
uses: hacs/action@main
|
||
with:
|
||
category: integration
|
||
|
||
hassfest:
|
||
name: "Hassfest: манифест интеграции"
|
||
needs: changes
|
||
if: needs.changes.outputs.integration == 'true'
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v7
|
||
- name: Hassfest validation
|
||
uses: home-assistant/actions/hassfest@master
|
||
|
||
frontend:
|
||
name: "Фронтенд: типы, юниты, мутанты, синхрон бандла"
|
||
needs: changes
|
||
if: needs.changes.outputs.frontend == 'true'
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
# Полная история без блобов (#342): гейт «новый код не добавляет any»
|
||
# diff-aware, ему нужен диапазон, а содержимое старых ревизий — нет.
|
||
- uses: actions/checkout@v7
|
||
with: { fetch-depth: 0, filter: 'blob:none' }
|
||
- uses: actions/setup-node@v7
|
||
with:
|
||
node-version: 22
|
||
cache: npm
|
||
- run: npm ci
|
||
- name: Typecheck
|
||
run: npm run typecheck
|
||
# Долг типизации снимается при извлечении подсистем (#34), а не разовой
|
||
# заменой: в src/** его 1034 вхождения в 49 файлах. Гейт держит приращение
|
||
# на нуле — судит только добавленные строки (#342).
|
||
- name: "Новый код не добавляет any"
|
||
env:
|
||
EVENT_NAME: ${{ github.event_name }}
|
||
BEFORE_SHA: ${{ github.event.before }}
|
||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||
PROVEN_BASE: ${{ needs.changes.outputs.range_base }}
|
||
HEAD_SHA: ${{ github.sha }}
|
||
REF: ${{ github.ref }}
|
||
run: |
|
||
set -euo pipefail
|
||
git fetch -q origin dev
|
||
zero=$(printf '%040d' 0)
|
||
base=""
|
||
# Пуш прямо в dev: диапазон — то, что добавлено с последнего
|
||
# ДОКАЗАННО зелёного предка (#388), а не с головы предыдущего пуша:
|
||
# его прогон штатно отменяется следующим, и добавленные им строки не
|
||
# судил бы никто. Фолбэк — прежний `before`.
|
||
if [ "$EVENT_NAME" != "pull_request" ] && [ "$REF" = "refs/heads/dev" ]; then
|
||
base="${PROVEN_BASE:-$BEFORE_SHA}"
|
||
fi
|
||
# Ветка, PR, новая история: точки отсчёта нет, берём merge-base с dev.
|
||
# Там диапазон и так шире, поэтому #388 этих случаев не касается.
|
||
if [ -z "$base" ] || [ "$base" = "$zero" ] \
|
||
|| ! git cat-file -e "$base" 2>/dev/null; then
|
||
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "")
|
||
fi
|
||
if [ -z "$base" ]; then
|
||
echo "не нашёл базу диапазона — гейт пропущен, это увидит следующий пуш"
|
||
exit 0
|
||
fi
|
||
node scripts/no-new-any.mjs --base "$base" --head "$HEAD_SHA"
|
||
- name: Unit tests
|
||
run: npm test
|
||
- name: Build
|
||
run: npm run build
|
||
# Entry, manifest and every content-hashed chunk are one artifact (#337).
|
||
- name: Card bundle trees in sync
|
||
run: |
|
||
node scripts/bundle-tree.mjs dist custom_components/houseplan/frontend
|
||
npm run bundle:budget
|
||
# Бандл собирается один раз и уезжает артефактом (#336). Прежде каждая из
|
||
# пяти браузерных job собирала его заново: `bundle:sync` — это `tsc
|
||
# --noEmit` плюс rollup, около минуты на job.
|
||
#
|
||
# Подмену артефакта ловить не нужно отдельной проверкой: `assertFreshDemoBundle`
|
||
# сверяет вшитый в бандл отпечаток с `sourceFingerprint` выкачанного
|
||
# дерева, и каждая браузерная job делает это перед первым кадром.
|
||
- name: Выложить бандл для браузерных job
|
||
uses: actions/upload-artifact@v7
|
||
with:
|
||
name: card-bundle
|
||
path: dist/
|
||
retention-days: 1
|
||
if-no-files-found: error
|
||
# Несколько браузерных контрактов импортируют чистые функции из
|
||
# скомпилированного тестового дерева. Оно создаётся `npm test`, но не
|
||
# входит в production-бандл и потому едет отдельным артефактом (#336).
|
||
- name: Выложить тестовые модули для смоков
|
||
uses: actions/upload-artifact@v7
|
||
with:
|
||
name: card-test-build
|
||
path: test-build/
|
||
retention-days: 1
|
||
if-no-files-found: error
|
||
|
||
smoke:
|
||
name: Смоки в браузере (шард ${{ matrix.shard }} из 3)
|
||
# Gated on `frontend` so a typecheck failure does not burn browser minutes.
|
||
needs: [frontend, reuse]
|
||
if: needs.reuse.outputs.smoke != 'true'
|
||
runs-on: ubuntu-latest
|
||
# Смоки шардируются: последовательный прогон занимал ~7.5 минут и был
|
||
# критическим путём всего Validate. Три шарда режут его примерно вдвое;
|
||
# цена — трижды `npm ci` и сборка, около двух оплаченных минут раннера.
|
||
strategy:
|
||
fail-fast: false
|
||
matrix:
|
||
shard: [1, 2, 3]
|
||
steps:
|
||
- uses: actions/checkout@v7
|
||
- uses: actions/setup-node@v7
|
||
with:
|
||
node-version: 22
|
||
cache: npm
|
||
- run: npm ci
|
||
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
|
||
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
|
||
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
|
||
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
|
||
# внятной ошибкой — тогда флаг вернуть.
|
||
- name: Кэш браузеров Playwright
|
||
id: pw
|
||
uses: actions/cache@v6
|
||
with:
|
||
path: ~/.cache/ms-playwright
|
||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||
- name: Install pinned Chromium
|
||
if: steps.pw.outputs.cache-hit != 'true'
|
||
run: npx playwright install chromium
|
||
# Бандл приходит артефактом из job `frontend` (#336): та же сборка, но
|
||
# один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle`
|
||
# сверяет вшитый отпечаток с исходниками этого дерева.
|
||
- name: Забрать собранный бандл
|
||
uses: actions/download-artifact@v7
|
||
with:
|
||
name: card-bundle
|
||
path: dist
|
||
- name: Забрать тестовые модули
|
||
uses: actions/download-artifact@v7
|
||
with:
|
||
name: card-test-build
|
||
path: test-build
|
||
- name: Разложить бандл по копиям
|
||
run: node scripts/bundle-sync.mjs
|
||
- name: Smoke suite (шард ${{ matrix.shard }} из 3)
|
||
env:
|
||
SHARD: ${{ matrix.shard }}
|
||
SHARDS: '3'
|
||
run: |
|
||
fail=0
|
||
index=0
|
||
ran=0
|
||
mkdir -p /tmp/smoke-logs
|
||
# Деление по порядковому номеру файла: список отсортирован, поэтому
|
||
# разбиение детерминировано и не зависит от времени прогона.
|
||
for f in demo/smoke_*.mjs; do
|
||
index=$((index + 1))
|
||
if [ $(( (index - 1) % SHARDS + 1 )) -ne "$SHARD" ]; then continue; fi
|
||
ran=$((ran + 1))
|
||
name=$(basename "$f" .mjs)
|
||
if node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then
|
||
echo "ok $name"
|
||
else
|
||
echo "FAIL $name"
|
||
tail -20 "/tmp/smoke-logs/$name.log"
|
||
fail=1
|
||
fi
|
||
done
|
||
echo "--- шард ${SHARD}/${SHARDS}: прогнано ${ran} из ${index}"
|
||
# Пустой шард — признак, что деление сломалось, а не что работы нет.
|
||
if [ "$ran" -eq 0 ]; then echo "шард пуст: проверьте деление"; exit 1; fi
|
||
exit $fail
|
||
- name: Upload smoke logs
|
||
if: failure()
|
||
uses: actions/upload-artifact@v7
|
||
with:
|
||
name: smoke-logs-${{ matrix.shard }}
|
||
path: /tmp/smoke-logs
|
||
|
||
# Маркер переиспользования пишется ОДИН раз и только когда прошли все шарды:
|
||
# частично прогнанная матрица не имеет права выглядеть как выполненная работа.
|
||
smoke_done:
|
||
name: "Смоки: все шарды зелёные"
|
||
needs: [smoke, reuse]
|
||
if: needs.reuse.outputs.smoke != 'true'
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- name: Записать маркер успеха
|
||
run: |
|
||
printf '%s\n' "smoke прогнана успешно (3 шарда)" \
|
||
"SHA: ${{ github.sha }}" \
|
||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||
> .reuse-marker
|
||
- uses: actions/cache/save@v6
|
||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||
# краснеть из-за этого не должна.
|
||
continue-on-error: true
|
||
with:
|
||
path: .reuse-marker
|
||
key: reuse-smoke-${{ needs.reuse.outputs.smoke_key }}
|
||
|
||
golden:
|
||
name: Golden-кадры против принятых эталонов
|
||
# Deterministic visual correctness stays in every prerelease gate: it is
|
||
# inexpensive and catches a different class of regressions than timings.
|
||
needs: [frontend, reuse]
|
||
if: needs.reuse.outputs.golden != 'true'
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v7
|
||
- uses: actions/setup-node@v7
|
||
with:
|
||
node-version: 22
|
||
cache: npm
|
||
- run: npm ci
|
||
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
|
||
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
|
||
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
|
||
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
|
||
# внятной ошибкой — тогда флаг вернуть.
|
||
- name: Кэш браузеров Playwright
|
||
id: pw
|
||
uses: actions/cache@v6
|
||
with:
|
||
path: ~/.cache/ms-playwright
|
||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||
- name: Install pinned Chromium
|
||
if: steps.pw.outputs.cache-hit != 'true'
|
||
run: npx playwright install chromium
|
||
# Бандл приходит артефактом из job `frontend` (#336): та же сборка, но
|
||
# один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle`
|
||
# сверяет вшитый отпечаток с исходниками этого дерева.
|
||
- name: Забрать собранный бандл
|
||
uses: actions/download-artifact@v7
|
||
with:
|
||
name: card-bundle
|
||
path: dist
|
||
- name: Разложить бандл по копиям
|
||
run: node scripts/bundle-sync.mjs
|
||
# Маркер предыдущего падения с ТЕМ ЖЕ ключом (#386). Восстанавливается до
|
||
# прогона, потому что после падения шаги кэша уже не помогут отличить
|
||
# виновника от свидетеля.
|
||
- name: Прежнее падение на тех же входах
|
||
uses: actions/cache/restore@v6
|
||
with:
|
||
path: .fail-marker
|
||
key: fail-golden-${{ needs.reuse.outputs.golden_key }}
|
||
- name: Capture or verify golden matrix
|
||
id: golden
|
||
run: |
|
||
if find demo/golden/baselines -maxdepth 1 -name '*.png' -print -quit | grep -q .; then
|
||
echo "has_baselines=true" >> "$GITHUB_OUTPUT"
|
||
npm run golden:verify
|
||
else
|
||
echo "has_baselines=false" >> "$GITHUB_OUTPUT"
|
||
npm run golden:capture
|
||
fi
|
||
- name: Upload golden candidates/diffs
|
||
if: failure() || steps.golden.outputs.has_baselines == 'false'
|
||
uses: actions/upload-artifact@v7
|
||
with:
|
||
name: golden-images
|
||
path: artifacts/golden
|
||
# Красная golden обязана сказать, ЧЕЙ это долг (#386). Без этого письмо
|
||
# «Run failed» называет коммит, который пушнули следующим: 29 августа так
|
||
# был назван документ ревью `0f7b6f5`, а сцену без эталона добавил
|
||
# `dbbe94ae`. Вердикт job не меняется — меняется только формулировка.
|
||
- name: Кто уронил golden
|
||
id: fail_note
|
||
if: failure()
|
||
env:
|
||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||
run: node scripts/gate-reuse.mjs --job=golden --note --marker=.fail-marker
|
||
- name: Запомнить первопричину
|
||
if: failure() && steps.fail_note.outputs.first == 'true'
|
||
# Гонка двух прогонов даёт «Cache already exists»; для маркера падения
|
||
# это тем более не отказ гейта — job и так красная по существу.
|
||
continue-on-error: true
|
||
uses: actions/cache/save@v6
|
||
with:
|
||
path: .fail-marker
|
||
key: fail-golden-${{ needs.reuse.outputs.golden_key }}
|
||
# Маркер пишется последним шагом: он существует только если всё выше
|
||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||
- name: Записать маркер успеха
|
||
run: |
|
||
printf '%s\n' "golden прогнана успешно" \
|
||
"SHA: ${{ github.sha }}" \
|
||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||
> .reuse-marker
|
||
- uses: actions/cache/save@v6
|
||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||
# краснеть из-за этого не должна.
|
||
continue-on-error: true
|
||
with:
|
||
path: .reuse-marker
|
||
key: reuse-golden-${{ needs.reuse.outputs.golden_key }}
|
||
|
||
performance_smoke:
|
||
name: "Перф-смок: бюджет времени кадра"
|
||
# Candidate-only catastrophic-regression guard for ordinary pushes and
|
||
# prereleases. The expensive same-runner comparison lives in performance.yml.
|
||
needs: [frontend, reuse]
|
||
if: needs.reuse.outputs.performance_smoke != 'true'
|
||
runs-on: ubuntu-latest
|
||
# 15 минут не хватало, когда установка браузера шла через apt: замер
|
||
# начинался на исходе окна (#206). Запас на холодный кэш — при попадании
|
||
# job укладывается в те же минуты, что и раньше.
|
||
timeout-minutes: 20
|
||
steps:
|
||
- uses: actions/checkout@v7
|
||
- uses: actions/setup-node@v7
|
||
with:
|
||
node-version: 22
|
||
cache: npm
|
||
- run: npm ci
|
||
# Браузеры кэшируются, а apt не запускается вовсе: на GitHub-раннере
|
||
# системные библиотеки Chromium уже в образе, а --with-deps тратил минуты
|
||
# и подолгу перебирал недоступное azure-зеркало (#175, #206). Если
|
||
# библиотека когда-нибудь исчезнет из образа, Chromium не запустится с
|
||
# внятной ошибкой — тогда флаг вернуть.
|
||
- name: Кэш браузеров Playwright
|
||
id: pw
|
||
uses: actions/cache@v6
|
||
with:
|
||
path: ~/.cache/ms-playwright
|
||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||
- name: Install pinned Chromium
|
||
if: steps.pw.outputs.cache-hit != 'true'
|
||
run: npx playwright install chromium
|
||
# Бандл приходит артефактом из job `frontend` (#336): та же сборка, но
|
||
# один раз на прогон. Свежесть проверяет сам продукт — `assertFreshDemoBundle`
|
||
# сверяет вшитый отпечаток с исходниками этого дерева.
|
||
- name: Забрать собранный бандл
|
||
uses: actions/download-artifact@v7
|
||
with:
|
||
name: card-bundle
|
||
path: dist
|
||
- name: Разложить бандл по копиям
|
||
run: node scripts/bundle-sync.mjs
|
||
- name: Capture the heaviest Glow state
|
||
run: |
|
||
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/candidate.json
|
||
npm run benchmark:glow -- --profile=large-space-card-glow-v1 --variants=60 --samples=3 --warmups=1 --output=artifacts/performance-smoke/space-candidate.json
|
||
- name: Enforce absolute smoke ceilings
|
||
run: |
|
||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-glow-smoke.json --candidate=artifacts/performance-smoke/candidate.json --output=artifacts/performance-smoke/comparison.json
|
||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-space-glow-smoke.json --candidate=artifacts/performance-smoke/space-candidate.json --output=artifacts/performance-smoke/space-comparison.json
|
||
# #330 AC7: перф-контракт ограничений стыков — быстрый (без браузера),
|
||
# ловит возврат квадратичных путей в оба зеркала прямо на пуше.
|
||
- name: Бюджеты ограничений стыков (#330)
|
||
run: npm run benchmark:junction-limits
|
||
- name: Upload performance smoke report
|
||
if: always()
|
||
uses: actions/upload-artifact@v7
|
||
with:
|
||
name: performance-smoke
|
||
path: artifacts/performance-smoke
|
||
# Маркер пишется последним шагом: он существует только если всё выше
|
||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||
- name: Записать маркер успеха
|
||
run: |
|
||
printf '%s\n' "performance_smoke прогнана успешно" \
|
||
"SHA: ${{ github.sha }}" \
|
||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||
> .reuse-marker
|
||
- uses: actions/cache/save@v6
|
||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||
# краснеть из-за этого не должна.
|
||
continue-on-error: true
|
||
with:
|
||
path: .reuse-marker
|
||
key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}
|
||
|
||
backend:
|
||
name: "Бэкенд: pytest в Home Assistant"
|
||
needs: [changes, reuse]
|
||
if: needs.changes.outputs.backend == 'true' && needs.reuse.outputs.backend != 'true'
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v7
|
||
# Browser fixtures are generated by their real ESM factories and then
|
||
# validated through the Python CONFIG_SCHEMA/LAYOUT_SCHEMA in the same test.
|
||
- uses: actions/setup-node@v7
|
||
with: { node-version: 22 }
|
||
- uses: actions/setup-python@v7
|
||
with: { python-version: "3.14" }
|
||
- run: |
|
||
# Точные версии — в tests_backend/requirements.txt (#392): без них
|
||
# резолвер выбирает набор сам, и «зелёный backend» значит разное
|
||
# в разные дни. #42 ставит из того же файла ruff для шага линта.
|
||
pip install -r tests_backend/requirements.txt
|
||
pip list --format=columns | grep -Ei 'homeassistant|voluptuous|^pytest '
|
||
- name: Линт бэкенда (ruff, узкий набор)
|
||
run: python -m ruff check custom_components/houseplan
|
||
# #42: типизация была измеримой только локально — CI её не исполнял, и
|
||
# регрессия в любом из allowlist-модулей проходила молча. Список модулей
|
||
# берётся ИЗ pyproject.toml, а не дублируется здесь: разошедшийся дубль
|
||
# означал бы зелёный шаг, проверяющий не то. Пустой список — отказ.
|
||
- name: Типы бэкенда (mypy strict по allowlist)
|
||
run: |
|
||
modules=$(python -c "
|
||
import tomllib
|
||
cfg = tomllib.load(open('pyproject.toml', 'rb'))
|
||
for o in cfg['tool']['mypy'].get('overrides', []):
|
||
if o.get('strict'):
|
||
print(' '.join('-p ' + m for m in o['module']))
|
||
")
|
||
test -n "$modules" || { echo '::error::strict-allowlist в pyproject.toml пуст — проверять нечего'; exit 1; }
|
||
echo "mypy strict: $modules"
|
||
python -m mypy $modules
|
||
# #42: защита от тихого скипа HA-harness — импорт и порог collect
|
||
- name: HA-harness присутствует
|
||
run: |
|
||
python -c "import homeassistant"
|
||
collected=$(python -m pytest tests_backend/ -q --collect-only 2>/dev/null | grep -c "test_ha_" || true)
|
||
echo "collected HA-harness tests: $collected"
|
||
test "$collected" -ge 50
|
||
- name: Backend unit tests (pure + HA harness, coverage)
|
||
run: >
|
||
python -m pytest tests_backend/ -q
|
||
--cov=custom_components/houseplan --cov-branch
|
||
--cov-report=xml --cov-report=term
|
||
# #42: гейт «не ниже baseline» — пороги 90/95 поднимаются trivial-правкой числа
|
||
- name: Порог покрытия не ниже baseline
|
||
run: |
|
||
baseline=$(head -1 scripts/backend-coverage-baseline.txt)
|
||
actual=$(python -c "import xml.etree.ElementTree as ET; print(round(float(ET.parse('coverage.xml').getroot().get('line-rate'))*100, 1))")
|
||
echo "coverage: $actual% (baseline $baseline%)"
|
||
python -c "import sys; sys.exit(0 if float('$actual') >= float('$baseline') - 0.1 else 1)"
|
||
- name: Артефакт coverage.xml
|
||
uses: actions/upload-artifact@v4
|
||
with:
|
||
name: backend-coverage
|
||
path: coverage.xml
|
||
# Маркер пишется последним шагом: он существует только если всё выше
|
||
# прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job.
|
||
- name: Записать маркер успеха
|
||
run: |
|
||
printf '%s\n' "backend прогнана успешно" \
|
||
"SHA: ${{ github.sha }}" \
|
||
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|
||
> .reuse-marker
|
||
- uses: actions/cache/save@v6
|
||
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
|
||
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
|
||
# краснеть из-за этого не должна.
|
||
continue-on-error: true
|
||
with:
|
||
path: .reuse-marker
|
||
key: reuse-backend-${{ needs.reuse.outputs.backend_key }}
|