mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
scripts/check-inputs.mjs declares every Validate check with its roots and entry points and computes the rest: imports and path literals of the entries, transitively for code, as leaves for data. classify-changes and gate-reuse both read it, so "which job runs" and "what its key hashes" cannot disagree any more. An executable file no check knows widens the run to the full set and is named in the summary; the coverage list makes such a file a red unit test rather than a permanent widening. The workflow file is a toolchain input of every job; backend no longer hashes src/**. Issue: #492 User-Visible: no
1044 lines
62 KiB
YAML
1044 lines
62 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:
|
||
# Полный набор тяжёлых job по кнопке и по ночам (#479): nightly.yml делает
|
||
# `gh workflow run validate.yml --ref dev -f full=true`.
|
||
workflow_dispatch:
|
||
inputs:
|
||
full:
|
||
description: 'Полный набор: смоки, golden, performance_smoke'
|
||
type: boolean
|
||
default: true
|
||
|
||
# A new push supersedes an unfinished validation for the same branch or PR.
|
||
# Exact-SHA release gates never depend on an obsolete commit.
|
||
# Полный прогон по кнопке/ночью (#479) живёт в своей группе: первый ручной
|
||
# запуск отменил очередной push конвейера через полторы минуты — ночной
|
||
# результат обнулялся бы любым коммитом, пришедшим в то же окно.
|
||
concurrency:
|
||
group: validate-${{ github.event_name == 'workflow_dispatch' && 'dispatch-' || '' }}${{ 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 }
|
||
|
||
# Свежесть скриншотов на обычном пуше — предупреждение, на кандидате беты
|
||
# (трейлер `Release:`), по кнопке и на PR — ошибка (#479). Остальные
|
||
# проверки документации красят всегда. Режим считает тот же скрипт, что и
|
||
# выход `heavy` job `changes`, чтобы два места не разошлись.
|
||
- name: "Документация: гайды, ченджлоги, скриншот-индекс"
|
||
id: docs
|
||
continue-on-error: true
|
||
env:
|
||
EVENT_NAME: ${{ github.event_name }}
|
||
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
|
||
FULL_INPUT: ${{ inputs.full }}
|
||
run: |
|
||
heavy=$(node scripts/classify-changes.mjs --heavy)
|
||
mode=warn
|
||
[ "$heavy" = "heavy=true" ] && mode=strict
|
||
echo "скриншоты документации: режим $mode ($heavy)"
|
||
node scripts/check-docs.mjs --external --screenshots=$mode
|
||
|
||
# Конвейер читает `process.yml` из ветки по умолчанию, поэтому файл обязан
|
||
# совпадать в `main` и `dev`. До этой проверки совпадение держалось на
|
||
# дисциплине: каждая правка требовала двух пушей и ручной сверки.
|
||
- name: "Процесс: process.yml и mutation-gate.yml идентичны в main и dev"
|
||
id: workflow_sync
|
||
continue-on-error: true
|
||
run: |
|
||
git fetch --quiet origin main dev
|
||
# #472: расписание mutation-gate.yml тоже исполняется из ветки по
|
||
# умолчанию — та же ловушка, что у process.yml. Сверяются оба.
|
||
status=0
|
||
for file in process.yml mutation-gate.yml; do
|
||
if diff <(git show "origin/main:.github/workflows/$file") \
|
||
<(git show "origin/dev:.github/workflows/$file"); then
|
||
echo "$file: main и dev идентичны"
|
||
else
|
||
echo "РАСХОЖДЕНИЕ: $file в main и dev различаются."
|
||
echo "Файл исполняется из ветки по умолчанию, поэтому"
|
||
echo "правку нужно отправить в обе ветки."
|
||
status=1
|
||
fi
|
||
done
|
||
exit $status
|
||
|
||
# Оба гейта ниже судят САМ диапазон коммитов, а не объём проверок, и до
|
||
# #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 и mutation-gate.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 }}
|
||
perf_iso: ${{ steps.classify.outputs.perf_iso }} # #473 §5
|
||
perf_interaction: ${{ steps.classify.outputs.perf_interaction }}
|
||
mutants: ${{ steps.classify.outputs.mutants }}
|
||
unknown_inputs: ${{ steps.classify.outputs.unknown_inputs }}
|
||
# Тяжёлые job только на кандидате/по кнопке/на PR (#479), см. шаг heavy.
|
||
heavy: ${{ steps.heavy.outputs.heavy }}
|
||
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) это дало зелёный статус ветки
|
||
# без единого исполненного тяжёлого гейта.
|
||
# Смоки, golden, performance_smoke — на кандидате беты (трейлер
|
||
# `Release:`), по `workflow_dispatch full=true`, на PR (#479). Логика и
|
||
# её тест — scripts/classify-changes.mjs.
|
||
- id: heavy
|
||
env:
|
||
EVENT_NAME: ${{ github.event_name }}
|
||
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
|
||
FULL_INPUT: ${{ inputs.full }}
|
||
run: node scripts/classify-changes.mjs --heavy | tee -a "$GITHUB_OUTPUT"
|
||
- 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"
|
||
node scripts/classify-changes.mjs --all >> "$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"
|
||
node scripts/classify-changes.mjs --all >> "$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 "база не определена — полный прогон без классификации"
|
||
node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT"
|
||
exit 0
|
||
fi
|
||
files=$(git diff --name-only "$base" "$HEAD_SHA")
|
||
printf '%s\n' "$files" | head -50
|
||
# Решение — в scripts/classify-changes.mjs по единому manifest входов
|
||
# (#492 §5.2): там оно проверяемо unit-тестом, здесь — только
|
||
# переписывается в выходы (#473 AC8).
|
||
printf '%s\n' "$files" | node scripts/classify-changes.mjs > /tmp/classify.out
|
||
tee -a "$GITHUB_OUTPUT" < /tmp/classify.out
|
||
# Файл, которого нет в manifest ни одной проверки, — не «не влияет»,
|
||
# а «неизвестно»: прогон расширен до полного набора, и это видно в
|
||
# summary, а не только в логе.
|
||
unknown=$(sed -n 's/^unknown_inputs=//p' /tmp/classify.out)
|
||
if [ -n "$unknown" ]; then
|
||
{
|
||
echo "### Неизвестные входы (#492)"
|
||
echo "Файлы, которых нет в manifest ни одной проверки — прогон расширен до полного набора:"
|
||
for f in $unknown; do echo "- \`$f\`"; done
|
||
echo "Впишите файл в \`CHECKS\` или \`NOT_AN_INPUT\` (scripts/check-inputs.mjs) с причиной."
|
||
} >> "$GITHUB_STEP_SUMMARY"
|
||
fi
|
||
|
||
# Переиспользование результата тяжёлой job (#208). Ключ = хеш содержимого
|
||
# всех входов job по единому manifest (#492, scripts/check-inputs.mjs):
|
||
# что собирает, что исполняет, что читает, чем запускается — включая
|
||
# `validate.yml`. Маркер в кэше пишет только успешный
|
||
# прогон с тем же ключом, поэтому попадание доказывает: job с побайтово теми
|
||
# же входами уже завершилась успешно.
|
||
#
|
||
# Это НЕ фильтр путей из job `changes` (на dev они отключены намеренно): там
|
||
# объём прогона угадывается по путям и «зелёный» начинает значить разное,
|
||
# здесь эквивалентность входов доказана хешем.
|
||
#
|
||
# Свойство, снимающее главный риск: релизный кандидат бампает версию, а
|
||
# CARD_VERSION и package.json входят в фингерпринт, поэтому ключи кандидата
|
||
# заведомо новые и полный набор гейтов перед бетой и релизом идёт всегда.
|
||
reuse:
|
||
name: "Переиспользование: это дерево уже проверено"
|
||
# `changes` нужна ради набора профилей перф-смока (#473): ключ
|
||
# performance_smoke включает набор, иначе glow-only результат засчитался бы
|
||
# прогону, которому нужен изометрический профиль.
|
||
needs: changes
|
||
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 }}
|
||
performance_smoke_set: ${{ steps.keys.outputs.performance_smoke_set }}
|
||
backend_key: ${{ steps.keys.outputs.backend }}
|
||
steps:
|
||
- uses: actions/checkout@v7
|
||
- uses: actions/setup-node@v7
|
||
with: { node-version: 22 }
|
||
- name: Ключи переиспользования
|
||
id: keys
|
||
env:
|
||
PERF_ISO: ${{ needs.changes.outputs.perf_iso }}
|
||
PERF_INTERACTION: ${{ needs.changes.outputs.perf_interaction }}
|
||
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
|
||
# Набор профилей перф-смока — часть ключа (#473 AC5).
|
||
set=glow
|
||
[ "$PERF_ISO" = "true" ] && set="$set-iso"
|
||
[ "$PERF_INTERACTION" = "true" ] && set="$set-interaction"
|
||
echo "performance_smoke_set=$set" >> "$GITHUB_OUTPUT"
|
||
echo "performance_smoke set: $set"
|
||
# 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 }}-${{ steps.keys.outputs.performance_smoke_set }}
|
||
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
|
||
|
||
# Мутанты по диффу (#475). Свидетель гниёт двумя способами: изменился
|
||
# файл, который он патчит, либо изменился его гард. `--check` этого не
|
||
# видит — он проверяет существование якоря, а не достижимость ветки.
|
||
# Полный прогон раз в неделю ловит гниль с опозданием до релиза (#466,
|
||
# #467). Здесь гоняются только затронутые мутанты с той же базы диапазона,
|
||
# что и остальные гейты ветки (#387/#388). Релизный диапазон всё же может
|
||
# задеть сотню свидетелей, поэтому #480 делит тот же набор на три
|
||
# детерминированных непересекающихся шарда вместо ослабления проверки.
|
||
changed_mutants:
|
||
name: "Мутанты по диффу (${{ matrix.shard }}/3): затронутые свидетели краснеют"
|
||
needs: changes
|
||
if: needs.changes.outputs.frontend == 'true' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.mutants == 'true'
|
||
strategy:
|
||
fail-fast: false
|
||
matrix:
|
||
shard: [1, 2, 3]
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 30
|
||
steps:
|
||
- uses: actions/checkout@v7
|
||
with:
|
||
fetch-depth: 0
|
||
- uses: actions/setup-node@v7
|
||
with:
|
||
node-version: 22
|
||
cache: npm
|
||
- run: npm ci
|
||
- uses: actions/setup-python@v7
|
||
with:
|
||
python-version: '3.14'
|
||
- name: Backend test dependencies
|
||
run: pip install -r tests_backend/requirements.txt
|
||
- name: Кэш браузеров Playwright
|
||
id: pw
|
||
uses: actions/cache@v6
|
||
with:
|
||
path: ~/.cache/ms-playwright
|
||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||
- name: Установить Chromium
|
||
if: steps.pw.outputs.cache-hit != 'true'
|
||
run: npx playwright install --with-deps chromium
|
||
# Журнал пойманных свидетелей (#481). Отменённый или упавший по таймауту
|
||
# прогон не пропадает: журнал пишется после каждого пойманного мутанта и
|
||
# сохраняется при любом исходе шага, а следующий пуш начинает с того
|
||
# места, где предыдущий остановился. Мутант с тем же отпечатком входов
|
||
# (файлы патча и гарда, без строки версии) повторно не гоняется.
|
||
- name: Журнал свидетелей (последний по шарду)
|
||
uses: actions/cache/restore@v6
|
||
with:
|
||
path: artifacts/mutation-ledger
|
||
key: mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}
|
||
restore-keys: mutation-ledger-${{ matrix.shard }}-
|
||
- name: Затронутые мутанты ловятся
|
||
env:
|
||
EVENT_NAME: ${{ github.event_name }}
|
||
BEFORE_SHA: ${{ github.event.before }}
|
||
PROVEN_BASE: ${{ needs.changes.outputs.range_base }}
|
||
HEAD_SHA: ${{ github.sha }}
|
||
REF: ${{ github.ref }}
|
||
SHARD: ${{ matrix.shard }}
|
||
run: |
|
||
set -euo pipefail
|
||
git fetch -q origin dev
|
||
zero=$(printf '%040d' 0)
|
||
base=""
|
||
if [ "$EVENT_NAME" != "pull_request" ] && [ "$REF" = "refs/heads/dev" ]; then
|
||
base="${PROVEN_BASE:-$BEFORE_SHA}"
|
||
fi
|
||
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
|
||
echo "диапазон: $base..$HEAD_SHA"
|
||
npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
|
||
node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/3" \
|
||
--ledger="artifacts/mutation-ledger/shard-$SHARD.json"
|
||
# Сохраняется всегда: и после красного шага (пойманные до отказа уже
|
||
# записаны), и при отмене прогона — post-шаг cache/save исполняется,
|
||
# пока job не убита целиком. Ключ уникален на прогон: cache не
|
||
# перезаписывает существующий ключ, а restore-keys берёт самый новый.
|
||
- name: Сохранить журнал свидетелей
|
||
if: always()
|
||
uses: actions/cache/save@v6
|
||
with:
|
||
path: artifacts/mutation-ledger
|
||
key: mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}
|
||
|
||
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: [changes, frontend, reuse]
|
||
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.smoke != 'true'
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 20
|
||
# Смоки шардируются: последовательный прогон занимал ~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
|
||
# Гард исключений внутри карточки объявлен в demo/serve.mjs с 2026-07-27 и
|
||
# до #404 не срабатывал ни разу: счётчик читался раньше, чем Playwright
|
||
# доставлял pageerror. Такое ловится только запуском, поэтому пробы живут
|
||
# здесь — в единственной job с настоящим браузером. Один шард из трёх:
|
||
# проверка не зависит от разбиения, а платить за неё трижды незачем.
|
||
- name: "Гард исключений умеет падать (#404)"
|
||
if: matrix.shard == 1
|
||
run: node demo/guard/verify-guard.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 timeout --kill-after=10s 180s node "$f" > "/tmp/smoke-logs/$name.log" 2>&1; then
|
||
echo "ok $name"
|
||
else
|
||
status=$?
|
||
echo "FAIL $name"
|
||
if [ "$status" -eq 124 ]; then
|
||
echo "diagnostic smoke-timeout: $name exceeded 180s"
|
||
fi
|
||
# Диагностические строки смока печатаются раньше вердикта, и
|
||
# `tail -20` их срезал: на #411 сам смок сообщал, НАСКОЛЬКО
|
||
# разошлись кадры (введено в #302), а в логе прогона осталось
|
||
# только «expected true, got false». Числа теперь достаются
|
||
# адресно — они и есть разница между «чинить» и «гадать».
|
||
grep -aE 'pixel-diffs|^EXC |diagnostic' "/tmp/smoke-logs/$name.log" | tail -5 || true
|
||
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: [changes, smoke, reuse]
|
||
if: needs.changes.outputs.heavy == 'true' && 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: [changes, frontend, reuse]
|
||
if: needs.changes.outputs.heavy == 'true' && 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: [changes, frontend, reuse]
|
||
if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.performance_smoke != 'true'
|
||
runs-on: ubuntu-latest
|
||
# 15 минут не хватало, когда установка браузера шла через apt: замер
|
||
# начинался на исходе окна (#206). Запас на холодный кэш — при попадании
|
||
# job укладывается в те же минуты, что и раньше. Диффозависимые профили
|
||
# (#473) добавляют до двух замеров по 3 образца — окно расширено под них.
|
||
timeout-minutes: 30
|
||
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
|
||
# Диффозависимые профили (#473 §5): изометрия — когда дифф задел
|
||
# `src/iso-*`, взаимодействие — когда живой путь или оркестраторы кадра.
|
||
# Абсолютные потолки — `hardMaxMs` полных профилей: регрессия #160
|
||
# (9 870 мс против 3 500) ловится с одного образца, шум — нет.
|
||
- name: Изометрический профиль по диффу (#473)
|
||
if: needs.changes.outputs.perf_iso == 'true'
|
||
run: |
|
||
npm run benchmark:large-house -- --profile=large-house-isometric-v1 --samples=3 --warmups=1 --output=artifacts/performance-smoke/isometric-candidate.json
|
||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-isometric-smoke.json --candidate=artifacts/performance-smoke/isometric-candidate.json --output=artifacts/performance-smoke/isometric-comparison.json
|
||
- name: Профиль взаимодействия по диффу (#473)
|
||
if: needs.changes.outputs.perf_interaction == 'true'
|
||
run: |
|
||
npm run benchmark:large-house -- --profile=large-house-interaction-v1 --samples=3 --warmups=1 --output=artifacts/performance-smoke/interaction-candidate.json
|
||
npm run benchmark:compare -- --absolute-only --budgets=demo/performance/budgets-interaction-smoke.json --candidate=artifacts/performance-smoke/interaction-candidate.json --output=artifacts/performance-smoke/interaction-comparison.json
|
||
# #330 AC7: перф-контракт ограничений стыков — быстрый (без браузера),
|
||
# ловит возврат квадратичных путей в оба зеркала прямо на пуше.
|
||
- name: Бюджеты ограничений стыков (#330)
|
||
run: npm run benchmark:junction-limits
|
||
- name: Быстрый commit точки стены (#461)
|
||
run: npm run benchmark:wall-draw-click -- --output=artifacts/performance-smoke/wall-draw-click.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 }}-${{ needs.reuse.outputs.performance_smoke_set }}
|
||
|
||
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
|
||
- name: Support relay unit tests
|
||
run: python -m unittest discover -s scripts/support-relay/tests -q
|
||
# #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 }}
|