mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
682 lines
37 KiB
YAML
682 lines
37 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
|
||
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
|
||
|
||
- name: "Провенанс коммитов: трейлеры и эталоны"
|
||
id: provenance
|
||
continue-on-error: true
|
||
env:
|
||
EVENT_NAME: ${{ github.event_name }}
|
||
BEFORE_SHA: ${{ 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: ${{ 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
|
||
outputs:
|
||
frontend: ${{ steps.classify.outputs.frontend }}
|
||
backend: ${{ steps.classify.outputs.backend }}
|
||
integration: ${{ steps.classify.outputs.integration }}
|
||
steps:
|
||
# `git diff --name-only` содержимого файлов не читает вовсе, поэтому
|
||
# блобы истории этой job не нужны ни на одном шаге (#345).
|
||
- uses: actions/checkout@v7
|
||
with: { fetch-depth: 0, filter: 'blob:none' }
|
||
- id: classify
|
||
env:
|
||
EVENT_NAME: ${{ github.event_name }}
|
||
BEFORE_SHA: ${{ github.event.before }}
|
||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||
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)
|
||
base="$BEFORE_SHA"
|
||
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
|
||
# #347: непустой before, которого больше не существует, — это
|
||
# force-push (ребейз/аменд переписал историю ветки). Диапазон в
|
||
# этом случае НЕ угадывается: merge-base после переписанной истории
|
||
# уже выдавал «изменились только два doc-файла» при реальном диффе
|
||
# в custom_components/**, и тяжёлые job тихо скипались, а общий
|
||
# статус рана оставался success — ровно класс тихого пропуска
|
||
# #171/#207. Force-push редок, полный прогон дешевле ложного
|
||
# «зелёного».
|
||
if [ -n "$base" ] && [ "$base" != "$zero" ] \
|
||
&& ! git cat-file -e "$base" 2>/dev/null; then
|
||
echo "force-push: before=$base переписан — полный прогон без классификации"
|
||
{
|
||
echo "### Классификация изменённых файлов"
|
||
echo "\`before\` (=$base) переписан force-push'ем: диапазон недоказуем,"
|
||
echo "все тяжёлые job запускаются без фильтра путей (#347)."
|
||
} >> "$GITHUB_STEP_SUMMARY"
|
||
printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT"
|
||
exit 0
|
||
fi
|
||
# Новая ветка: before нулевой, диапазон считается от merge-base с dev,
|
||
# иначе классифицировалась бы вся история.
|
||
if [ -z "$base" ] || [ "$base" = "$zero" ]; then
|
||
git fetch -q origin dev
|
||
base=$(git merge-base origin/dev "$HEAD_SHA" || echo "$HEAD_SHA~1")
|
||
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 }}
|
||
HEAD_SHA: ${{ github.sha }}
|
||
REF: ${{ github.ref }}
|
||
run: |
|
||
set -euo pipefail
|
||
git fetch -q origin dev
|
||
zero=$(printf '%040d' 0)
|
||
base="$BEFORE_SHA"
|
||
if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi
|
||
# Пуш прямо в dev: диапазон — то, что этот пуш добавил. Новая ветка или
|
||
# перезаписанная история: точки отсчёта нет, берём merge-base с dev.
|
||
if [ "$REF" != "refs/heads/dev" ] || [ -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
|
||
- 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
|
||
# Маркер пишется последним шагом: он существует только если всё выше
|
||
# прошло. Кэш сохраняется 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.13" }
|
||
- run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
|
||
- name: Backend unit tests (pure + HA harness)
|
||
run: python -m pytest tests_backend/ -q
|
||
# Маркер пишется последним шагом: он существует только если всё выше
|
||
# прошло. Кэш сохраняется 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 }}
|