Files
houseplan-card/.github/workflows/validate.yml
T
Claude bdac4b4fdc ci: закрепить образ раннера, таймауты job и некруглые cron (#658)
`ubuntu-latest` с 19.10.2026 переезжает на Ubuntu 26, а golden, скриншоты
документации и перф-бюджеты сняты на текущем образе: все 43 job на раннере
теперь явно на `ubuntu-24.04`, один образ на все workflow. 26 job получили
`timeout-minutes` по наблюдённой длительности с запасом; гейт релиза — 180,
больше суммы собственных ожиданий (60 + 60 + 45). Расписания ушли с круглых
минут (ночь 02:17, мутанты 00:43, метрики 05:23, полный перф 04:11), ночь
пишет в summary сдвиг старта и предупреждает, если он больше часа.

test/workflow-hygiene.test.mjs держит все три правила по тексту workflow
(разбор `parseJobSettings` в scripts/workflow-jobs.mjs) и исполняет шаг
сдвига старта настоящим bash; порядок осознанного подъёма образа —
docs/DEVELOPMENT.md.

Issue: #658
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-27 13:57:21 +03:00

1362 lines
85 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (без мутантов — #601)'
type: boolean
default: true
# Мутанты по диффу бегут только по явному запросу (#510, #601):
# ревью-конвейер и слияние кандидата запускают `-f full=false
# -f mutants=true` на материале. `full=true`, трейлер `Release:` и ночь
# их не включают — ночью идёт полный реестр (mutation-gate.yml).
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-24.04
timeout-minutes: 15
# `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/<тонкий файл>`: по блобу на файл.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: { fetch-depth: 0, filter: 'blob:none' }
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # 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: |
# #586: CLI отдаёт ОДИН ответ. Прежде здесь сравнивался со строкой
# `heavy=true` весь вывод `--heavy`, а он двухстрочный: в `$(…)`
# строки схлопываются через пробел, сравнение не совпадало никогда,
# и строгий режим не включился ни на одном кандидате.
mode=$(node scripts/classify-changes.mjs --screenshots-mode)
echo "скриншоты документации: режим $mode"
node scripts/check-docs.mjs --external --screenshots=$mode
# #635: `docs/reviews/INDEX.md` — снимок каталога ревью; расхождение с
# каталогом — невидимые через индекс документы (r2 #635 H1). Гейт стоит
# только на push в dev: там свежесть держит конвейер — пересборка при
# публикации документа и после каждого своего ребейза (приведение к dev,
# слияние кандидата). На issue-ветках не судится: их ребейзит и
# публикует в них конвейер из ветки по умолчанию, и доехавший туда
# документ раунда индекс ветки знать не обязан — красить автора за
# коммит конвейера нельзя; расхождение излечивает слияние.
- name: "Индекс ревью совпадает с каталогом docs/reviews"
id: reviews_index
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
continue-on-error: true
run: node scripts/reviews-index.mjs --dir=docs/reviews --check
# #623: для событий `issues`, `schedule` и `workflow_run` GitHub берёт
# workflow из ветки по умолчанию (`main`). Там лежат тонкие вызывающие
# файлы — триггеры, run-name, права, concurrency, — а тело каждого
# (`_<имя>.yml`) они вызывают по ссылке `@dev`. Правка конвейера — один
# коммит в `dev`; тела здесь не сверяются: их копия в `main` не исполняется
# ни одним событием. Тонкий файл сверяется: правка триггера или потолка
# прав, не доехавшая до `main`, действовала бы только в dev-копии.
#
# `performance.yml` не входит: по расписанию он судит `main` собственным
# телом из `main`, расхождение с `dev` до промоушена законно. Список ниже
# держит равным множеству файлов с такими триггерами
# test/default-branch-workflows.test.mjs.
- name: "Процесс: тонкие вызывающие workflow идентичны в main и dev"
id: workflow_sync
continue-on-error: true
run: |
git fetch --quiet origin main dev
status=0
for file in process.yml mutation-gate.yml process-resume.yml nightly.yml process-reconcile.yml process-metrics.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 "поэтому правку триггеров или прав нужно зеркалить в main (#623)."
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 стоило бы автору лишнего круга «починил одно — узнал о
# втором».
# #556: перемещаемая ссылка в `uses:` — доверие чужому владельцу тега
# здесь и сейчас, а не коду, который читали. Проверка стоит в preflight:
# она стоит миллисекунды и ловит правку, которая иначе доехала бы до
# привилегированной стадии.
- name: "Сторонние Actions закреплены полным SHA"
id: action_pins
continue-on-error: true
run: node scripts/action-pins.mjs
- name: Вердикт предполётных проверок
env:
DOCS: ${{ steps.docs.outcome }}
WORKFLOW_SYNC: ${{ steps.workflow_sync.outcome }}
PROVENANCE: ${{ steps.provenance.outcome }}
PROCESS_GATE: ${{ steps.process_gate.outcome }}
ACTION_PINS: ${{ steps.action_pins.outcome }}
REVIEWS_INDEX: ${{ steps.reviews_index.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 "тонкие вызывающие workflow в main и dev" "$WORKFLOW_SYNC"
check "провенанс коммитов" "$PROVENANCE"
check "процессный гейт" "$PROCESS_GATE"
check "пины сторонних Actions" "$ACTION_PINS"
# skipped — шаг не судил (не push в dev), это не отказ
[ "$REVIEWS_INDEX" = "skipped" ] || check "индекс ревью совпадает с каталогом" "$REVIEWS_INDEX"
exit $fail
# Классификация изменённых путей: тяжёлые job идут только там, где менялось
# относящееся к ним. НА DEV ФИЛЬТРОВ НЕТ: гейт беты принимает «зелёный Validate
# на точном SHA», и если объём прогона зависит от diff, «зелёный» перестаёт
# значить одно и то же — кандидат релиза (манифесты + changelog) пропустил бы
# браузерные тесты, а прогон с пропущенными job всё равно success. Фильтры
# экономят на ветках задач, где Validate — ранний сигнал: настоящую приёмку
# там делает код-ревью, которое гоняет гейты само (#127).
changes:
name: Классификация изменённых файлов
runs-on: ubuntu-24.04
timeout-minutes: 10
# Чтение списка прогонов Validate — единственное, что этой job нужно сверх
# содержимого репозитория (#387).
permissions:
contents: read
actions: read
outputs:
frontend: ${{ steps.classify.outputs.frontend }}
geometry_parity: ${{ steps.classify.outputs.geometry_parity }}
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/main (#388, #619).
range_base: ${{ steps.base.outputs.range_base }}
steps:
# `git diff --name-only` содержимого файлов не читает вовсе, поэтому
# блобы истории этой job не нужны ни на одном шаге (#345).
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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" ] || [ "$REF" = "refs/heads/main" ]; then
# На интеграционных ветках классифицировать нечего (всё true), но
# база диапазона нужна гейту «новый код не добавляет any» в job
# frontend (#388, #619).
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" ] || [ "$REF" = "refs/heads/main" ]; then
echo "dev/main: без фильтров, всё 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-24.04
timeout-minutes: 10
outputs:
smoke: ${{ steps.probe.outputs.smoke }}
golden: ${{ steps.probe.outputs.golden }}
performance_smoke: ${{ steps.probe.outputs.performance_smoke }}
geometry_parity: ${{ steps.probe.outputs.geometry_parity }}
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 }}
geometry_parity_key: ${{ steps.keys.outputs.geometry_parity }}
backend_key: ${{ steps.keys.outputs.backend }}
smoke_source_run: ${{ steps.p_smoke.outputs.source_run }}
smoke_source_attempt: ${{ steps.p_smoke.outputs.source_attempt }}
smoke_source_sha: ${{ steps.p_smoke.outputs.source_sha }}
golden_source_run: ${{ steps.p_golden.outputs.source_run }}
golden_source_attempt: ${{ steps.p_golden.outputs.source_attempt }}
golden_source_sha: ${{ steps.p_golden.outputs.source_sha }}
performance_smoke_source_run: ${{ steps.p_perf.outputs.source_run }}
performance_smoke_source_attempt: ${{ steps.p_perf.outputs.source_attempt }}
performance_smoke_source_sha: ${{ steps.p_perf.outputs.source_sha }}
geometry_parity_source_run: ${{ steps.p_geometry_parity.outputs.source_run }}
geometry_parity_source_attempt: ${{ steps.p_geometry_parity.outputs.source_attempt }}
geometry_parity_source_sha: ${{ steps.p_geometry_parity.outputs.source_sha }}
backend_source_run: ${{ steps.p_backend.outputs.source_run }}
backend_source_attempt: ${{ steps.p_backend.outputs.source_attempt }}
backend_source_sha: ${{ steps.p_backend.outputs.source_sha }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # 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 geometry_parity 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"
# Маркер восстанавливается во временный файл: #541 требует не только
# cache-hit, но и SHA/run исходного успешного job. Между lookup файл
# удаляется, чтобы один маркер не был принят за другой.
- name: Маркер smoke
id: m_smoke
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: .reuse-marker
key: reuse-smoke-${{ steps.keys.outputs.smoke }}
- name: Доказательство источника smoke
id: p_smoke
if: steps.m_smoke.outputs.cache-hit == 'true'
run: node scripts/ci-proof.mjs --marker=.reuse-marker
- name: Очистить маркер smoke перед следующим lookup
if: always()
run: rm -f .reuse-marker
- name: Маркер golden
id: m_golden
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: .reuse-marker
key: reuse-golden-${{ steps.keys.outputs.golden }}
- name: Доказательство источника golden
id: p_golden
if: steps.m_golden.outputs.cache-hit == 'true'
run: node scripts/ci-proof.mjs --marker=.reuse-marker
- name: Очистить маркер golden перед следующим lookup
if: always()
run: rm -f .reuse-marker
- name: Маркер performance_smoke
id: m_perf
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: .reuse-marker
key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}-${{ steps.keys.outputs.performance_smoke_set }}
- name: Доказательство источника performance_smoke
id: p_perf
if: steps.m_perf.outputs.cache-hit == 'true'
run: node scripts/ci-proof.mjs --marker=.reuse-marker
- name: Очистить маркер performance_smoke перед следующим lookup
if: always()
run: rm -f .reuse-marker
- name: Маркер geometry_parity
id: m_geometry_parity
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: .reuse-marker
key: reuse-geometry_parity-${{ steps.keys.outputs.geometry_parity }}
- name: Доказательство источника geometry_parity
id: p_geometry_parity
if: steps.m_geometry_parity.outputs.cache-hit == 'true'
run: node scripts/ci-proof.mjs --marker=.reuse-marker
- name: Очистить маркер geometry_parity перед следующим lookup
if: always()
run: rm -f .reuse-marker
- name: Маркер backend
id: m_backend
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: .reuse-marker
key: reuse-backend-${{ steps.keys.outputs.backend }}
- name: Доказательство источника backend
id: p_backend
if: steps.m_backend.outputs.cache-hit == 'true'
run: node scripts/ci-proof.mjs --marker=.reuse-marker
- name: Очистить маркер backend
if: always()
run: rm -f .reuse-marker
- 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 }}
GEOMETRY_PARITY: ${{ steps.m_geometry_parity.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 geometry_parity "$GEOMETRY_PARITY"
waive backend "$BACKEND"
hacs:
name: "HACS: валидация репозитория"
needs: changes
if: needs.changes.outputs.integration == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: HACS validation
uses: hacs/action@1ebf01c408f29afcb6406bd431bc98fd8cbb15aa # main@2026-09-13
with:
category: integration
hassfest:
name: "Hassfest: манифест интеграции"
needs: changes
if: needs.changes.outputs.integration == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Hassfest validation
uses: home-assistant/actions/hassfest@58bff37c8947f690ace498be413a9b78d6f30f93 # master@2026-09-13
# Мутанты по диффу (#475). Свидетель гниёт двумя способами: изменился
# файл, который он патчит, либо изменился его гард. `--check` этого не
# видит — он проверяет существование якоря, а не достижимость ветки.
# Полный прогон раз в неделю ловит гниль с опозданием до релиза (#466,
# #467). Здесь гоняются только затронутые мутанты с той же базы диапазона,
# что и остальные гейты ветки (#387/#388). Релизный диапазон всё же может
# задеть сотню свидетелей, поэтому #480 делит тот же набор на три
# детерминированных непересекающихся шарда вместо ослабления проверки.
# С #510 job бежит не на каждом пуше, а на кандидате: ревью-конвейер и
# слияние кандидата запускают Validate по кнопке с `mutants=true` на
# материале; PR берёт её сам. За 08–09.09 на промежуточных пушах она стоила
# 48 из 56 часов job-минут и в основном отменялась следующим пушем. С #601
# её не запрашивают ни `full=true`, ни кандидат беты (`Release:`): к бете
# каждая задача уже прогнана мутантами на ревью и на слитом кандидате, а
# ночь покрыта полным реестром. Когда мутанты запрошены, 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-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
cache: npm
# Журнал пойманных свидетелей (#481). Отменённый или упавший по таймауту
# прогон не пропадает: журнал пишется после каждого пойманного мутанта и
# сохраняется при любом исходе шага, а следующая попытка или пуш начинает
# с того места, где предыдущий остановился. Мутант с тем же отпечатком входов
# (область якоря и файлы гарда, без строки версии) повторно не гоняется.
# Восстанавливается ДО плана (#518): план обязан видеть журнал, иначе
# пропуск станет известен только после установки окружения.
- name: Журнал свидетелей (последний по шарду)
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # 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"
plan="$RUNNER_TEMP/mutation-plan-$SHARD.txt"
node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/6" \
--ledger="artifacts/mutation-ledger/shard-$SHARD.json" --plan-only > "$plan"
cat "$plan"
count=$(sed -n 's/^plan=//p' "$plan" | tail -1)
echo "к прогону в этом шарде: ${count:-0}"
echo "count=${count:-0}" >> "$GITHUB_OUTPUT"
# #620: план называет и окружение своих гардов. Непрочитанная строка
# при непустом плане значит «ставить»: лишняя установка стоит минуты,
# недоустановка — красный чистый прогон и лишний круг задачи.
if [ "${count:-0}" != "0" ]; then
browser=$(sed -n 's/^plan-browser=//p' "$plan" | tail -1)
python=$(sed -n 's/^plan-python=//p' "$plan" | tail -1)
echo "окружение шарда: браузер ${browser:-true}, Python ${python:-true}"
echo "browser=${browser:-true}" >> "$GITHUB_OUTPUT"
echo "python=${python:-true}" >> "$GITHUB_OUTPUT"
fi
- if: steps.plan.outputs.count != '0'
run: npm ci
# Python и зависимости бэкенда — только шарду с pytest-гардами (#620);
# колёса pip кэшируются по requirements.txt.
- if: steps.plan.outputs.count != '0' && steps.plan.outputs.python == 'true'
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: '3.14'
cache: pip
cache-dependency-path: tests_backend/requirements.txt
- name: Backend test dependencies
if: steps.plan.outputs.count != '0' && steps.plan.outputs.python == 'true'
run: pip install -r tests_backend/requirements.txt
# Chromium — только шарду с браузерными гардами (#620): смоки, golden и
# юниты, чей граф исполнения импортирует Playwright.
- name: Кэш браузеров Playwright
id: pw
if: steps.plan.outputs.count != '0' && steps.plan.outputs.browser == 'true'
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- name: Установить Chromium
if: steps.plan.outputs.count != '0' && steps.plan.outputs.browser == 'true' && 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@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # 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-24.04
timeout-minutes: 40
steps:
# Полная история без блобов (#342): гейт «новый код не добавляет any»
# diff-aware, ему нужен диапазон, а содержимое старых ревизий — нет.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with: { fetch-depth: 0, filter: 'blob:none' }
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
cache: npm
- run: npm ci
- name: Typecheck
run: npm run typecheck
# Долг типизации снимается при извлечении подсистем (#34), а не разовой
# заменой: в src/** его 1034 вхождения в 49 файлах. Гейт держит приращение
# на нуле — судит только добавленные строки (#342). Тем же диапазоном
# судится и приращение записей смоков в приватное состояние карточки
# (#629): больше двух тысяч старых не трогаем, новых не добавляем.
- 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/main: диапазон — то, что добавлено с последнего
# ДОКАЗАННО зелёного предка (#388, #619), а не с головы предыдущего
# пуша: его прогон штатно отменяется следующим, и добавленные им
# строки не судил бы никто. Фолбэк — прежний `before`.
if [ "$EVENT_NAME" != "pull_request" ] \
&& { [ "$REF" = "refs/heads/dev" ] || [ "$REF" = "refs/heads/main" ]; }; 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
status=0
node scripts/no-new-any.mjs --base "$base" --head "$HEAD_SHA" || status=1
node scripts/no-new-private-writes.mjs --base "$base" --head "$HEAD_SHA" || status=1
exit "$status"
- name: Build
run: npm run build
# Часть unit-контрактов проверяет размеры реально поставляемых графов в
# dist/ (#438, #593, #627). В issue-ветке закоммиченный dist законно
# отстаёт от исходников (#657), поэтому юниты обязаны читать свежую
# сборку — тот же порядок закреплён локальным gate:small.
- name: Unit tests
run: npm test
# Entry, manifest and every content-hashed chunk are one artifact (#337).
# #657: the committed bundle changes only in a release candidate, so the
# fresh build must equal it only there; elsewhere the committed copy
# legitimately lags and only the fresh dist is verified.
- name: Card bundle trees in sync
run: |
node scripts/bundle-policy.mjs --verify HEAD
npm run bundle:budget
# #624: `tsc --noUnusedLocals` чист вне порта/харнесса, шесть чисел
# связности монолита не выше scripts/monolith-baseline.json. После
# сборки — одно из чисел считает байты dist/.
- name: Мёртвый код и связность монолита
run: npm run lint:unused
# Бандл собирается один раз и уезжает артефактом (#336). Прежде каждая из
# пяти браузерных job собирала его заново: `bundle:sync` — это `tsc
# --noEmit` плюс rollup, около минуты на job.
#
# Подмену артефакта ловить не нужно отдельной проверкой: `assertFreshDemoBundle`
# сверяет вшитый в бандл отпечаток с `sourceFingerprint` выкачанного
# дерева, и каждая браузерная job делает это перед первым кадром.
- name: Выложить бандл для браузерных job
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: card-bundle
path: dist/
retention-days: 1
if-no-files-found: error
# Несколько браузерных контрактов импортируют чистые функции из
# скомпилированного тестового дерева. Оно создаётся `npm test`, но не
# входит в production-бандл и потому едет отдельным артефактом (#336).
- name: Выложить тестовые модули для смоков
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: card-test-build
path: test-build/
retention-days: 1
if-no-files-found: error
# #657 (2б): бандл в дереве dev меняет только кандидат беты, поэтому стенд
# dev.houseplan.tech берёт его не из дерева, а из этого артефакта: после
# зелёного фронтенда на push в dev собранный `dist/` публикуется в служебную
# ветку `dev-build` одним коммитом без истории (scripts/dev-build.mjs), хост
# забирает его demo/stand/update-dev-bundle.sh. Опубликуется только голова:
# если dev ушёл вперёд, пушит следующий прогон. Не вход доказательства:
# стенд — не гейт, и сбой публикации не красит проверку кода.
dev_build:
name: "Бандл головы dev для стенда"
needs: frontend
if: github.event_name == 'push' && github.ref == 'refs/heads/dev' && needs.frontend.result == 'success'
runs-on: ubuntu-24.04
timeout-minutes: 10
continue-on-error: true
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
# download-artifact распаковывает поверх checkout и не удаляет старые
# content-hashed чанки закоммиченного release-бандла (#657). Свежий
# артефакт должен быть единственным содержимым dist, иначе bundle-tree
# справедливо видит оставшиеся файлы как orphan assets.
- name: Очистить закоммиченный dist перед артефактом
run: |
node -e "require('node:fs').rmSync('dist', { recursive: true, force: true })"
- name: Забрать собранный бандл
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: card-bundle
path: dist
- name: Опубликовать в dev-build
run: node scripts/dev-build.mjs --sha "$GITHUB_SHA" --dist dist
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-24.04
timeout-minutes: 20
# Смоки шардируются: последовательный прогон занимал ~7.5 минут и был
# критическим путём всего Validate. Три шарда режут его примерно вдвое;
# цена — трижды `npm ci` и сборка, около двух оплаченных минут раннера.
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # 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@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # 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: Очистить закоммиченный dist перед артефактом
run: |
node -e "require('node:fs').rmSync('dist', { recursive: true, force: true })"
- name: Забрать собранный бандл
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: card-bundle
path: dist
- name: Забрать тестовые модули
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # 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-24.04
timeout-minutes: 10
steps:
- name: Записать маркер успеха
run: |
printf '%s\n' "smoke прогнана успешно (3 шарда)" \
"SHA: ${{ github.sha }}" \
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
"попытка: ${{ github.run_attempt }}" \
> .reuse-marker
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # 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-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # 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@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # 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: Очистить закоммиченный dist перед артефактом
run: |
node -e "require('node:fs').rmSync('dist', { recursive: true, force: true })"
- name: Забрать собранный бандл
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: card-bundle
path: dist
- name: Разложить бандл по копиям
run: node scripts/bundle-sync.mjs
# Маркер предыдущего падения с ТЕМ ЖЕ ключом (#386). Восстанавливается до
# прогона, потому что после падения шаги кэша уже не помогут отличить
# виновника от свидетеля.
- name: Прежнее падение на тех же входах
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # 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@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # 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 }}" \
"попытка: ${{ github.run_attempt }}" \
> .reuse-marker
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # 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-24.04
# 15 минут не хватало, когда установка браузера шла через apt: замер
# начинался на исходе окна (#206). Запас на холодный кэш — при попадании
# job укладывается в те же минуты, что и раньше. Диффозависимые профили
# (#473) добавляют до двух замеров по 3 образца — окно расширено под них.
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # 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@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # 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: Очистить закоммиченный dist перед артефактом
run: |
node -e "require('node:fs').rmSync('dist', { recursive: true, force: true })"
- name: Забрать собранный бандл
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # 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 }}" \
"попытка: ${{ github.run_attempt }}" \
> .reuse-marker
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # 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 }}
geometry_parity:
name: "Геометрия: TS/Python parity исполнена"
needs: [changes, reuse]
if: needs.changes.outputs.geometry_parity == 'true' && needs.reuse.outputs.geometry_parity != 'true'
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version-file: .nvmrc
cache: npm
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version-file: .python-version
- run: npm ci
- name: Компилировать узкое TS-зеркало П1–П4
run: |
npx --no-install tsc -p tsconfig.junction-parity.json
node scripts/fix-test-build.mjs
- name: Сравнить настоящие TS/Python validators (fail closed)
run: python tests_backend/junction_parity.py --build-dir=test-build/junction-parity
- name: Записать маркер успеха
run: |
printf '%s\n' "geometry_parity прогнана успешно" \
"SHA: ${{ github.sha }}" \
"прогон: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
"попытка: ${{ github.run_attempt }}" \
> .reuse-marker
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
continue-on-error: true
with:
path: .reuse-marker
key: reuse-geometry_parity-${{ needs.reuse.outputs.geometry_parity_key }}
backend:
name: "Бэкенд: pytest в Home Assistant"
needs: [changes, reuse]
if: needs.changes.outputs.backend == 'true' && needs.reuse.outputs.backend != 'true'
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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@820762786026740c76f36085b0efc47a31fe5020 # v7
with: { node-version: 22 }
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # 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 }}" \
"попытка: ${{ github.run_attempt }}" \
> .reuse-marker
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
# Гонка двух прогонов с одинаковым ключом даёт «Cache already exists».
# Это не отказ гейта: работа выполнена, маркер уже записал сосед — job
# краснеть из-за этого не должна.
continue-on-error: true
with:
path: .reuse-marker
key: reuse-backend-${{ needs.reuse.outputs.backend_key }}
# #541: общий conclusion workflow не говорит, какие условные job реально
# исполнились, а какие были законно переиспользованы. Этот всегда исполняемый
# финальный job публикует неизменяемый proof, привязанный к SHA/tree,
# run_id/run_attempt и фактическим outputs всех prerequisite job. Consumers
# review/merge/release принимают Validate только через scripts/ci-proof.mjs.
proof:
name: "Доказательство выполненных проверок"
if: always()
needs: [preflight, changes, reuse, hacs, hassfest, changed_mutants, frontend, smoke, smoke_done, golden, performance_smoke, geometry_parity, backend]
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.sha }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with: { node-version: 22 }
- name: Зафиксировать tree кандидата
id: candidate
run: echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
# #573: proof несёт составное evidence — product tree без overlay эталонов,
# сам overlay (tree, sha256 индекса, run из `Baseline-Reviewed`) и
# content-ключи реюзных job; ключи сверяются с outputs job `reuse`, а
# потребитель на checkout кандидата пересчитывает всё заново.
- name: Собрать единый CI proof
env:
CANDIDATE_SHA: ${{ github.sha }}
CANDIDATE_TREE: ${{ steps.candidate.outputs.tree }}
CI_RUN_ID: ${{ github.run_id }}
CI_RUN_ATTEMPT: ${{ github.run_attempt }}
CI_EVENT: ${{ github.event_name }}
REQUEST_FULL: ${{ inputs.full }}
REQUEST_MUTANTS: ${{ inputs.mutants }}
NEEDS_JSON: ${{ toJSON(needs) }}
run: node scripts/ci-proof.mjs --emit=artifacts/ci-proof/proof.json
- name: Опубликовать proof точной попытки
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ci-proof-${{ github.run_id }}-${{ github.run_attempt }}
path: artifacts/ci-proof/proof.json
retention-days: 30
if-no-files-found: error