From 90472ee583bd55bc783fd43876683520e300780f Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sun, 6 Sep 2026 19:27:10 +0300 Subject: [PATCH] =?UTF-8?q?ci:=20nightly.yml=20=D0=B8=20workflow=5Fdispatc?= =?UTF-8?q?h=20Validate=20=D0=B2=20main=20(#479)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: #479 User-Visible: no --- .github/workflows/nightly.yml | 38 ++++++++++ .github/workflows/validate.yml | 133 +++++++++++++++++++++++++-------- 2 files changed, 138 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/nightly.yml diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 00000000..ca121e11 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,38 @@ +# Ночной полный прогон (#479). +# +# Тяжёлые job Validate — смоки, golden, performance_smoke — на обычном пуше не +# идут: они ни разу не ловили дефект в момент ревью и стоили ~6 минут +# критического пути на каждую итерацию. Полный набор идёт на кандидате беты +# (трейлер `Release:`), по кнопке и здесь — каждую ночь на голове `dev`. +# +# Почему не `schedule` прямо в validate.yml: расписание исполняется на ветке +# по умолчанию (`main`), а проверять надо `dev`. Один dispatch с `--ref dev` +# делает это без переписывания checkout во всех job. Reuse (#208) сохраняется: +# при неизменённом дереве ночной прогон обойдётся маркерами. +# +# Красный ночной прогон — сигнал автору последних коммитов на dev, не гейт: +# гейт беты по-прежнему требует зелёный Validate на точном SHA кандидата, и +# там полный набор идёт заново. +name: Ночной полный прогон dev + +on: + schedule: + - cron: '30 2 * * *' + workflow_dispatch: {} + +permissions: + actions: write + contents: read + +jobs: + dispatch: + name: "Запустить Validate на dev с полным набором" + runs-on: ubuntu-latest + steps: + - env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + gh workflow run validate.yml --repo "$REPO" --ref dev -f full=true + echo "Validate(dev, full=true) поставлен в очередь: $(date -u +%FT%TZ)" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index d7674ab7..7aa6f23d 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -13,6 +13,14 @@ on: paths-ignore: - 'docs/reviews/**' pull_request: + # Полный набор тяжёлых job по кнопке и по ночам (#479): nightly.yml делает + # `gh workflow run validate.yml --ref dev -f full=true`. + workflow_dispatch: + inputs: + full: + description: 'Полный набор: смоки, golden, performance_smoke' + type: boolean + default: true # A new push supersedes an unfinished validation for the same branch or PR. # Exact-SHA release gates never depend on an obsolete commit. @@ -53,28 +61,47 @@ jobs: - uses: actions/setup-node@v7 with: { node-version: 22 } + # Свежесть скриншотов на обычном пуше — предупреждение, на кандидате беты + # (трейлер `Release:`), по кнопке и на PR — ошибка (#479). Остальные + # проверки документации красят всегда. Режим считает тот же скрипт, что и + # выход `heavy` job `changes`, чтобы два места не разошлись. - name: "Документация: гайды, ченджлоги, скриншот-индекс" id: docs continue-on-error: true - run: node scripts/check-docs.mjs --external + env: + EVENT_NAME: ${{ github.event_name }} + HEAD_MESSAGE: ${{ github.event.head_commit.message }} + FULL_INPUT: ${{ inputs.full }} + run: | + heavy=$(node scripts/classify-changes.mjs --heavy) + mode=warn + [ "$heavy" = "heavy=true" ] && mode=strict + echo "скриншоты документации: режим $mode ($heavy)" + node scripts/check-docs.mjs --external --screenshots=$mode # Конвейер читает `process.yml` из ветки по умолчанию, поэтому файл обязан # совпадать в `main` и `dev`. До этой проверки совпадение держалось на # дисциплине: каждая правка требовала двух пушей и ручной сверки. - - name: "Процесс: process.yml идентичен в main и dev" + - name: "Процесс: process.yml и mutation-gate.yml идентичны в main и dev" id: workflow_sync continue-on-error: true run: | git fetch --quiet origin main dev - if diff <(git show origin/main:.github/workflows/process.yml) \ - <(git show origin/dev:.github/workflows/process.yml); then - echo "main и dev идентичны" - else - echo "РАСХОЖДЕНИЕ: process.yml в main и dev различаются." - echo "Конвейер исполняет версию из ветки по умолчанию, поэтому" - echo "правку нужно отправить в обе ветки." - exit 1 - fi + # #472: расписание mutation-gate.yml тоже исполняется из ветки по + # умолчанию — та же ловушка, что у process.yml. Сверяются оба. + status=0 + for file in process.yml mutation-gate.yml; do + if diff <(git show "origin/main:.github/workflows/$file") \ + <(git show "origin/dev:.github/workflows/$file"); then + echo "$file: main и dev идентичны" + else + echo "РАСХОЖДЕНИЕ: $file в main и dev различаются." + echo "Файл исполняется из ветки по умолчанию, поэтому" + echo "правку нужно отправить в обе ветки." + status=1 + fi + done + exit $status # Оба гейта ниже судят САМ диапазон коммитов, а не объём проверок, и до # #388 брали его от головы предыдущего пуша. Прогон предыдущего пуша @@ -158,7 +185,7 @@ jobs: } echo "### Предполётные проверки" >> "$GITHUB_STEP_SUMMARY" check "документация" "$DOCS" - check "process.yml в main и dev" "$WORKFLOW_SYNC" + check "process.yml и mutation-gate.yml в main и dev" "$WORKFLOW_SYNC" check "провенанс коммитов" "$PROVENANCE" check "процессный гейт" "$PROCESS_GATE" exit $fail @@ -182,6 +209,11 @@ jobs: frontend: ${{ steps.classify.outputs.frontend }} backend: ${{ steps.classify.outputs.backend }} integration: ${{ steps.classify.outputs.integration }} + # Диффозависимые профили перф-смока (#473 §5). + perf_iso: ${{ steps.classify.outputs.perf_iso }} + perf_interaction: ${{ steps.classify.outputs.perf_interaction }} + # Тяжёлые job только на кандидате/по кнопке/на PR (#479), см. шаг heavy. + heavy: ${{ steps.heavy.outputs.heavy }} base: ${{ steps.base.outputs.base }} # Разные вещи под разными именами намеренно: `base` — до какого коммита # классифицировать файлы ветки (#387), `range_base` — от какого коммита @@ -198,6 +230,15 @@ jobs: # то есть допущение «до этого всё проверено». Concurrency отменяет прогон # предыдущего пуша штатно, и на #86 (r5) это дало зелёный статус ветки # без единого исполненного тяжёлого гейта. + # Смоки, golden, performance_smoke — на кандидате беты (трейлер + # `Release:`), по `workflow_dispatch full=true`, на PR (#479). Логика и + # её тест — scripts/classify-changes.mjs. + - id: heavy + env: + EVENT_NAME: ${{ github.event_name }} + HEAD_MESSAGE: ${{ github.event.head_commit.message }} + FULL_INPUT: ${{ inputs.full }} + run: node scripts/classify-changes.mjs --heavy | tee -a "$GITHUB_OUTPUT" - id: base if: github.event_name != 'pull_request' env: @@ -237,7 +278,7 @@ jobs: run: | if [ "$REF" = "refs/heads/dev" ]; then echo "dev: без фильтров, всё true" - printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT" + node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT" exit 0 fi zero=$(printf '%040d' 0) @@ -261,7 +302,7 @@ jobs: echo "\`before\` (=$BEFORE_SHA) переписан force-push'ем: диапазон недоказуем," echo "все тяжёлые job запускаются без фильтра путей (#347)." } >> "$GITHUB_STEP_SUMMARY" - printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT" + node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT" exit 0 fi # Диапазон считается от последнего ДОКАЗАННО зелёного предка (#387), @@ -271,17 +312,14 @@ jobs: if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi if [ -z "$base" ]; then echo "база не определена — полный прогон без классификации" - printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT" + 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 - has() { printf '%s\n' "$files" | grep -qE "$1" && echo true || echo false; } - { - echo "frontend=$(has '^(src/|demo/|test/|dist/|custom_components/houseplan/frontend/|package(-lock)?\.json$|rollup\.config\.mjs$|tsconfig)')" - echo "backend=$(has '^(custom_components/.*\.py$|tests_backend/|scripts/support-relay/|pytest\.ini$)')" - echo "integration=$(has '^(custom_components/houseplan/manifest\.json$|hacs\.json$|custom_components/.*\.py$|custom_components/.*/translations/)')" - } >> "$GITHUB_OUTPUT" + # Шаблоны — в scripts/classify-changes.mjs: там они проверяемы + # unit-тестом, здесь — только переписываются в выходы (#473 AC8). + printf '%s\n' "$files" | node scripts/classify-changes.mjs | tee -a "$GITHUB_OUTPUT" # Переиспользование результата тяжёлой job (#208). Ключ = входы поведения # (sourceFingerprint: src/**, demo/fixtures, demo/golden/*.mjs, манифесты @@ -298,6 +336,10 @@ jobs: # заведомо новые и полный набор гейтов перед бетой и релизом идёт всегда. reuse: name: "Переиспользование: это дерево уже проверено" + # `changes` нужна ради набора профилей перф-смока (#473): ключ + # performance_smoke включает набор, иначе glow-only результат засчитался бы + # прогону, которому нужен изометрический профиль. + needs: changes runs-on: ubuntu-latest outputs: smoke: ${{ steps.probe.outputs.smoke }} @@ -307,6 +349,7 @@ jobs: smoke_key: ${{ steps.keys.outputs.smoke }} golden_key: ${{ steps.keys.outputs.golden }} performance_smoke_key: ${{ steps.keys.outputs.performance_smoke }} + performance_smoke_set: ${{ steps.keys.outputs.performance_smoke_set }} backend_key: ${{ steps.keys.outputs.backend }} steps: - uses: actions/checkout@v7 @@ -314,12 +357,21 @@ jobs: with: { node-version: 22 } - name: Ключи переиспользования id: keys + env: + PERF_ISO: ${{ needs.changes.outputs.perf_iso }} + PERF_INTERACTION: ${{ needs.changes.outputs.perf_interaction }} run: | for job in smoke golden performance_smoke backend; do key=$(node scripts/gate-reuse.mjs --job="$job") echo "$job=$key" >> "$GITHUB_OUTPUT" echo "$job: $key" done + # Набор профилей перф-смока — часть ключа (#473 AC5). + set=glow + [ "$PERF_ISO" = "true" ] && set="$set-iso" + [ "$PERF_INTERACTION" = "true" ] && set="$set-interaction" + echo "performance_smoke_set=$set" >> "$GITHUB_OUTPUT" + echo "performance_smoke set: $set" # lookup-only: маркер только проверяется, но не восстанавливается — # сохранять его в этой job нечего, она ничего не прогоняла. - name: Маркер smoke @@ -341,7 +393,7 @@ jobs: uses: actions/cache/restore@v6 with: path: .reuse-marker - key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }} + key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}-${{ steps.keys.outputs.performance_smoke_set }} lookup-only: true - name: Маркер backend id: m_backend @@ -486,8 +538,8 @@ jobs: smoke: name: Смоки в браузере (шард ${{ matrix.shard }} из 3) # Gated on `frontend` so a typecheck failure does not burn browser minutes. - needs: [frontend, reuse] - if: needs.reuse.outputs.smoke != 'true' + needs: [changes, frontend, reuse] + if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.smoke != 'true' runs-on: ubuntu-latest timeout-minutes: 20 # Смоки шардируются: последовательный прогон занимал ~7.5 минут и был @@ -590,8 +642,8 @@ jobs: # частично прогнанная матрица не имеет права выглядеть как выполненная работа. smoke_done: name: "Смоки: все шарды зелёные" - needs: [smoke, reuse] - if: needs.reuse.outputs.smoke != 'true' + needs: [changes, smoke, reuse] + if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.smoke != 'true' runs-on: ubuntu-latest steps: - name: Записать маркер успеха @@ -613,8 +665,8 @@ jobs: name: Golden-кадры против принятых эталонов # Deterministic visual correctness stays in every prerelease gate: it is # inexpensive and catches a different class of regressions than timings. - needs: [frontend, reuse] - if: needs.reuse.outputs.golden != 'true' + needs: [changes, frontend, reuse] + if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.golden != 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -711,13 +763,14 @@ jobs: name: "Перф-смок: бюджет времени кадра" # Candidate-only catastrophic-regression guard for ordinary pushes and # prereleases. The expensive same-runner comparison lives in performance.yml. - needs: [frontend, reuse] - if: needs.reuse.outputs.performance_smoke != 'true' + needs: [changes, frontend, reuse] + if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.performance_smoke != 'true' runs-on: ubuntu-latest # 15 минут не хватало, когда установка браузера шла через apt: замер # начинался на исходе окна (#206). Запас на холодный кэш — при попадании - # job укладывается в те же минуты, что и раньше. - timeout-minutes: 20 + # job укладывается в те же минуты, что и раньше. Диффозависимые профили + # (#473) добавляют до двух замеров по 3 образца — окно расширено под них. + timeout-minutes: 30 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 @@ -757,6 +810,20 @@ jobs: 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) @@ -784,7 +851,7 @@ jobs: continue-on-error: true with: path: .reuse-marker - key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }} + key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}-${{ needs.reuse.outputs.performance_smoke_set }} backend: name: "Бэкенд: pytest в Home Assistant"