name: Validate 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: - '**' 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: docs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: { node-version: 22 } - name: Validate public documentation run: node scripts/check-docs.mjs --external provenance: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: { fetch-depth: 0 } - uses: actions/setup-node@v7 with: { node-version: 22 } - name: Validate commit trailers and hook mode 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 ловится машиной. Job независимый: краснеет сам и # не роняет остальные, откат — удалить его отсюда, скрипт остаётся рабочим. process-gate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: { fetch-depth: 0 } - uses: actions/setup-node@v7 with: { node-version: 22 } - name: Process gate 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 идут только там, где менялось # относящееся к ним. НА DEV ФИЛЬТРОВ НЕТ: гейт беты принимает «зелёный Validate # на точном SHA», и если объём прогона зависит от diff, «зелёный» перестаёт # значить одно и то же — кандидат релиза (манифесты + changelog) пропустил бы # браузерные тесты, а прогон с пропущенными job всё равно success. Фильтры # экономят на ветках задач, где Validate — ранний сигнал: настоящую приёмку # там делает код-ревью, которое гоняет гейты само (#127). changes: runs-on: ubuntu-latest outputs: frontend: ${{ steps.classify.outputs.frontend }} backend: ${{ steps.classify.outputs.backend }} integration: ${{ steps.classify.outputs.integration }} steps: - uses: actions/checkout@v7 with: { fetch-depth: 0 } - 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 # Новая ветка: before нулевой, диапазон считается от merge-base с dev, # иначе классифицировалась бы вся история. if [ -z "$base" ] || [ "$base" = "$zero" ] \ || ! git cat-file -e "$base" 2>/dev/null; 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: 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: 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: 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: needs: changes if: needs.changes.outputs.frontend == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 22 cache: npm - run: npm ci - name: Typecheck run: npm run typecheck - name: Unit tests run: npm test - name: Build run: npm run build - name: Card bundle snapshots in sync run: | cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js smoke: # 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 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 - name: Build a fresh bundle for the smokes run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js - name: Smoke suite run: | fail=0 mkdir -p /tmp/smoke-logs for f in demo/smoke_*.mjs; do 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 exit $fail - name: Upload smoke logs if: failure() uses: actions/upload-artifact@v7 with: name: smoke-logs path: /tmp/smoke-logs # Маркер пишется последним шагом: он существует только если всё выше # прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job. - name: Записать маркер успеха run: | printf '%s\n' "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-smoke-${{ needs.reuse.outputs.smoke_key }} 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 - name: Build the exact source under review run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js - 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: # 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 - name: Build the exact candidate source run: npm run build && cp dist/houseplan-card.js demo/srv/assets/houseplan-card.js - 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 - 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 - 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: 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 }}