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-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/<тонкий файл>`: по блобу на файл. - 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-latest # Чтение списка прогонов 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-latest 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-latest 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-latest 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-latest 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-latest 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: 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 # #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 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@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: Забрать собранный бандл 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-latest 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-latest 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: Забрать собранный бандл 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-latest # 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: Забрать собранный бандл 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-latest 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-latest 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-latest 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