mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
The review gate re-ran almost every selected witness on every round even when the executor's fix was twelve lines: the ledger fingerprint and the diff selection both worked on whole files, and the card hosts are thirteen thousand lines each. On #500 those twelve lines in houseplan-editor-runtime.ts pulled 53 of the 75 witnesses the third round ran, and the gate cost 140 job-minutes and an hour of the reviewer's wall clock across three rounds. The patch side is now judged by the anchor's neighbourhood — the anchor lines plus ANCHOR_RADIUS_LINES on each side — in both the ledger fingerprint and the diff selection, which now reads hunk ranges from git diff --unified=0. The guard side keeps whole-file granularity: a guard has no anchor and changes as a whole. An anchor that is not found exactly once falls back to the whole file, and so does a file whose hunks were not read: not knowing is not proof. Same class of approximation as the existing diff selection, with the nightly full gate (#513) as the floor. Two more cuts to the wall clock of a review round. The shard plan is now computed before the environment is installed — restore the ledger, select, split, and only then pay for npm ci, Python and Chromium; the job still runs, so the review gate's proof (#510) is unaffected. And the matrix goes from three shards to six: the same job-minutes, half the wall time. On the #500 round the selection drops 60 → 7. Four witnesses guard the new logic, including the two unsafe defaults (ambiguous anchor, missing hunks). Issue: #518 User-Visible: no
1090 lines
65 KiB
YAML
1090 lines
65 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
|
||
# Мутанты по диффу бегут по запросу (#510): ревью-конвейер и слияние
|
||
# кандидата запускают `-f full=false -f mutants=true` на материале.
|
||
mutants:
|
||
description: 'Мутанты по диффу на этом SHA (кандидат ревью)'
|
||
type: boolean
|
||
default: false
|
||
|
||
# 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 }}
|
||
mutants_requested: ${{ steps.heavy.outputs.mutants_requested }} # #510
|
||
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 }}
|
||
MUTANTS_INPUT: ${{ inputs.mutants }}
|
||
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 делит тот же набор на три
|
||
# детерминированных непересекающихся шарда вместо ослабления проверки.
|
||
# С #510 job бежит не на каждом пуше, а на кандидате: ревью-конвейер и
|
||
# слияние кандидата запускают Validate по кнопке с `mutants=true` на
|
||
# материале, ночной прогон/PR/кандидат беты берут её сами. За 08–09.09 на
|
||
# промежуточных пушах она стоила 48 из 56 часов job-минут и в основном
|
||
# отменялась следующим пушем. Когда мутанты запрошены, job бежит даже при
|
||
# диффе без входов (отбор пустой, минута на checkout): гейт ревью читает
|
||
# её исход по job, и skipped был бы неотличим от «не запрашивали».
|
||
changed_mutants:
|
||
name: "Мутанты по диффу (${{ matrix.shard }}/6): затронутые свидетели краснеют"
|
||
needs: changes
|
||
if: needs.changes.outputs.mutants_requested == 'true'
|
||
strategy:
|
||
fail-fast: false
|
||
matrix:
|
||
shard: [1, 2, 3, 4, 5, 6]
|
||
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
|
||
# Журнал пойманных свидетелей (#481). Отменённый или упавший по таймауту
|
||
# прогон не пропадает: журнал пишется после каждого пойманного мутанта и
|
||
# сохраняется при любом исходе шага, а следующая попытка или пуш начинает
|
||
# с того места, где предыдущий остановился. Мутант с тем же отпечатком входов
|
||
# (область якоря и файлы гарда, без строки версии) повторно не гоняется.
|
||
# Восстанавливается ДО плана (#518): план обязан видеть журнал, иначе
|
||
# пропуск станет известен только после установки окружения.
|
||
- name: Журнал свидетелей (последний по шарду)
|
||
uses: actions/cache/restore@v6
|
||
with:
|
||
path: artifacts/mutation-ledger
|
||
# Cache immutable: rerun с тем же run_id обязан получить новый key,
|
||
# иначе post-save не сможет записать прогресс поверх первой попытки.
|
||
key: mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||
restore-keys: |
|
||
mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}-
|
||
mutation-ledger-${{ matrix.shard }}-
|
||
# #518: план шарда считается до установки окружения. npm ci, Python с
|
||
# зависимостями бэкенда и Chromium — около трёх минут на шард, и раньше
|
||
# их платили даже тогда, когда гонять было нечего: правка задела чужой
|
||
# конец хост-файла или журнал уже держит всех отобранных. Плану хватает
|
||
# git и node из образа.
|
||
- name: План шарда
|
||
id: plan
|
||
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 "не нашёл базу диапазона — мутанты по диффу пропущены, это увидит следующий пуш"
|
||
echo "count=0" >> "$GITHUB_OUTPUT"
|
||
exit 0
|
||
fi
|
||
echo "диапазон: $base..$HEAD_SHA"
|
||
echo "base=$base" >> "$GITHUB_OUTPUT"
|
||
count=$(node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/6" \
|
||
--ledger="artifacts/mutation-ledger/shard-$SHARD.json" --plan-only \
|
||
| tee /dev/stderr | sed -n 's/^plan=//p' | tail -1)
|
||
echo "к прогону в этом шарде: ${count:-0}"
|
||
echo "count=${count:-0}" >> "$GITHUB_OUTPUT"
|
||
- if: steps.plan.outputs.count != '0'
|
||
run: npm ci
|
||
- if: steps.plan.outputs.count != '0'
|
||
uses: actions/setup-python@v7
|
||
with:
|
||
python-version: '3.14'
|
||
- name: Backend test dependencies
|
||
if: steps.plan.outputs.count != '0'
|
||
run: pip install -r tests_backend/requirements.txt
|
||
- name: Кэш браузеров Playwright
|
||
id: pw
|
||
if: steps.plan.outputs.count != '0'
|
||
uses: actions/cache@v6
|
||
with:
|
||
path: ~/.cache/ms-playwright
|
||
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||
- name: Установить Chromium
|
||
if: steps.plan.outputs.count != '0' && steps.pw.outputs.cache-hit != 'true'
|
||
run: npx playwright install --with-deps chromium
|
||
- name: Затронутые мутанты ловятся
|
||
if: steps.plan.outputs.count != '0'
|
||
env:
|
||
BASE: ${{ steps.plan.outputs.base }}
|
||
HEAD_SHA: ${{ github.sha }}
|
||
SHARD: ${{ matrix.shard }}
|
||
run: |
|
||
set -euo pipefail
|
||
npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
|
||
node scripts/mutation-gate.mjs --changed="$BASE..$HEAD_SHA" --shard="$SHARD/6" \
|
||
--ledger="artifacts/mutation-ledger/shard-$SHARD.json"
|
||
# Сохраняется всегда: и после красного шага (пойманные до отказа уже
|
||
# записаны), и при отмене прогона — post-шаг cache/save исполняется,
|
||
# пока job не убита целиком. Ключ уникален на попытку: cache не
|
||
# перезаписывает существующий ключ, а restore-keys берёт предыдущую
|
||
# попытку этого run либо самый новый журнал шарда.
|
||
- name: Сохранить журнал свидетелей
|
||
if: always()
|
||
uses: actions/cache/save@v6
|
||
with:
|
||
path: artifacts/mutation-ledger
|
||
key: mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||
|
||
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 }}
|