From 0d85510ba36c5079ae9a678de37536f70303df88 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 6 Sep 2026 15:38:04 +0300 Subject: [PATCH 1/6] =?UTF-8?q?ci:=20=D0=B7=D0=B5=D1=80=D0=BA=D0=B0=D0=BB?= =?UTF-8?q?=D0=BE=20mutation-gate.yml=20=D0=B8=D0=B7=20dev=20=E2=80=94=20?= =?UTF-8?q?=D0=B0=D0=B4=D1=80=D0=B5=D1=81=D0=B0=D1=82=20=D1=83=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=D0=B7=D0=BE=D0=B2=20=D1=80=D0=B0=D1=81=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Расписание исполняет файл из ветки по умолчанию; содержимое идентично dev. Issue: #472 User-Visible: no --- .github/workflows/mutation-gate.yml | 108 +++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mutation-gate.yml b/.github/workflows/mutation-gate.yml index fd3a9706..984bf18a 100644 --- a/.github/workflows/mutation-gate.yml +++ b/.github/workflows/mutation-gate.yml @@ -31,8 +31,13 @@ on: permissions: contents: read +# Группа зависит от события (#472). Прежде она была одна на всё, и ручной +# запуск перед релизом отменял идущий по расписанию — так 24.08 погиб +# еженедельный прогон, а отменённый в списке выглядит «не красным». Поймать +# отмену изнутри нельзя: вместе с прогоном отменяются и не начавшиеся job, +# включая любой репортёр. Значит отмену надо не ловить, а не допускать. concurrency: - group: mutation-gate + group: mutation-gate-${{ github.event_name }} cancel-in-progress: true jobs: @@ -88,5 +93,104 @@ jobs: - name: Тёплый test-build для инкрементальной компиляции мутантов run: npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs + # Вывод шарда сохраняется артефактом (#472): строки + # `FAIL : тест остался зелёным…` — единственное место, где названо, + # ЧТО сбежало. Без артефакта отказ безымянный. `PIPESTATUS` — чтобы + # `tee` не съел код выхода раннера. - name: Каждый тест ловит свою поломку - run: node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4 + run: | + mkdir -p artifacts + set -o pipefail + node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}.log + - name: Сохранить лог шарда + if: always() + uses: actions/upload-artifact@v7 + with: + name: mutation-shard-${{ matrix.shard }} + path: artifacts/mutation-shard-${{ matrix.shard }}.log + if-no-files-found: warn + retention-days: 30 + + # Адресат у отказа (#472). Только по расписанию: ручные прогоны перед + # релизом падают по замыслу, их результат владелец смотрит сам — issue на + # каждый такой отказ был бы шумом, который снова перестанут читать. + # + # Права job-уровня ЗАМЕНЯЮТ права workflow, а не дополняют (прецедент — + # validate.yml, job с actions: read): перечислены все три. + report: + name: "Отказ расписания: issue и Telegram" + needs: mutants + if: always() && github.event_name == 'schedule' && needs.mutants.result != 'success' + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + issues: write + steps: + - uses: actions/checkout@v7 + with: + ref: dev + - uses: actions/setup-node@v7 + with: + node-version: 22 + - name: Забрать логи шардов + uses: actions/download-artifact@v7 + with: + pattern: mutation-shard-* + path: artifacts/mutation-logs + - name: Собрать отчёт + id: report + env: + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + # SHA — того дерева, которое чекаутили и гоняли (dev), а не + # github.sha: для расписания это вершина default-ветки main, и отчёт + # называл бы «dev @ » (ревью r1). Образец — process.yml. + SHA=$(git rev-parse HEAD) + mkdir -p artifacts + node scripts/mutation-gate-report.mjs \ + --logs=artifacts/mutation-logs --shards=4 \ + --run-url="$RUN_URL" --ref=dev --sha="$SHA" \ + --body-out=artifacts/mutation-report.md \ + --telegram-out=artifacts/mutation-telegram.txt >> "$GITHUB_OUTPUT" + # Одно issue, а не одно на неделю: открытое с тем же маркером в заголовке + # получает комментарий, новое заводится только если открытого нет. + - name: Issue — создать или дописать + id: issue + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + MARKER: ${{ steps.report.outputs.marker }} + TITLE: ${{ steps.report.outputs.title }} + run: | + existing=$(gh issue list --repo "$REPO" --state open --search "\"$MARKER\" in:title" \ + --json number,title --jq '[.[] | select(.title | startswith(env.MARKER))][0].number // empty') + if [ -n "$existing" ]; then + gh issue comment "$existing" --repo "$REPO" --body-file artifacts/mutation-report.md + url="${{ github.server_url }}/$REPO/issues/$existing" + else + url=$(gh issue create --repo "$REPO" --title "$TITLE" \ + --label infra --label process --label tests \ + --body-file artifacts/mutation-report.md) + fi + echo "url=$url" >> "$GITHUB_OUTPUT" + echo "issue: $url" + # Тот же канал, что у релизов (announce.yml). Нет секретов — не отказ: + # issue уже заведено, а Telegram — второй адресат, не единственный. + - name: Telegram + if: always() && steps.issue.outcome == 'success' + env: + TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + CHAT: ${{ secrets.TELEGRAM_CHAT_ID }} + ISSUE_URL: ${{ steps.issue.outputs.url }} + run: | + if [ -z "$TOKEN" ] || [ -z "$CHAT" ]; then + echo "::warning::TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID не заданы — оповещение пропущено, issue заведено" + exit 0 + fi + TEXT=$(sed "s|(issue)|$ISSUE_URL|" artifacts/mutation-telegram.txt) + curl -sS --fail-with-body -X POST \ + "https://api.telegram.org/bot$TOKEN/sendMessage" \ + --data-urlencode "chat_id=$CHAT" \ + --data-urlencode "text=$TEXT" \ + -d disable_web_page_preview=true From 90472ee583bd55bc783fd43876683520e300780f Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sun, 6 Sep 2026 19:27:10 +0300 Subject: [PATCH 2/6] =?UTF-8?q?ci:=20nightly.yml=20=D0=B8=20workflow=5Fdis?= =?UTF-8?q?patch=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" From cba4eeba5621015924482da1dcd2961dec1db03f Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sun, 6 Sep 2026 19:31:51 +0300 Subject: [PATCH 3/6] =?UTF-8?q?ci:=20=D0=B2=D0=B5=D1=80=D0=BD=D1=83=D1=82?= =?UTF-8?q?=D1=8C=20validate.yml=20main=20=D0=BA=20=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D0=B6=D0=BD=D0=B5=D0=B9=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B8?= =?UTF-8?q?=20=E2=80=94=20=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=20dev=20=D0=B2=20main=20=D0=BD=D0=B5=D1=82=20(#479)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В main остаётся только nightly.yml: он диспатчит Validate на dev, где workflow_dispatch уже есть. Run 2643 падал на classify-changes.mjs. Issue: #479 User-Visible: no --- .github/workflows/validate.yml | 133 ++++++++------------------------- 1 file changed, 33 insertions(+), 100 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 7aa6f23d..d7674ab7 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -13,14 +13,6 @@ 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. @@ -61,47 +53,28 @@ jobs: - uses: actions/setup-node@v7 with: { node-version: 22 } - # Свежесть скриншотов на обычном пуше — предупреждение, на кандидате беты - # (трейлер `Release:`), по кнопке и на PR — ошибка (#479). Остальные - # проверки документации красят всегда. Режим считает тот же скрипт, что и - # выход `heavy` job `changes`, чтобы два места не разошлись. - name: "Документация: гайды, ченджлоги, скриншот-индекс" id: docs continue-on-error: true - env: - EVENT_NAME: ${{ github.event_name }} - HEAD_MESSAGE: ${{ github.event.head_commit.message }} - FULL_INPUT: ${{ inputs.full }} - run: | - heavy=$(node scripts/classify-changes.mjs --heavy) - mode=warn - [ "$heavy" = "heavy=true" ] && mode=strict - echo "скриншоты документации: режим $mode ($heavy)" - node scripts/check-docs.mjs --external --screenshots=$mode + run: node scripts/check-docs.mjs --external # Конвейер читает `process.yml` из ветки по умолчанию, поэтому файл обязан # совпадать в `main` и `dev`. До этой проверки совпадение держалось на # дисциплине: каждая правка требовала двух пушей и ручной сверки. - - name: "Процесс: process.yml и mutation-gate.yml идентичны в main и dev" + - name: "Процесс: process.yml идентичен в main и dev" id: workflow_sync continue-on-error: true run: | git fetch --quiet origin main dev - # #472: расписание mutation-gate.yml тоже исполняется из ветки по - # умолчанию — та же ловушка, что у process.yml. Сверяются оба. - status=0 - for file in process.yml mutation-gate.yml; do - if diff <(git show "origin/main:.github/workflows/$file") \ - <(git show "origin/dev:.github/workflows/$file"); then - echo "$file: main и dev идентичны" - else - echo "РАСХОЖДЕНИЕ: $file в main и dev различаются." - echo "Файл исполняется из ветки по умолчанию, поэтому" - echo "правку нужно отправить в обе ветки." - status=1 - fi - done - exit $status + 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 # Оба гейта ниже судят САМ диапазон коммитов, а не объём проверок, и до # #388 брали его от головы предыдущего пуша. Прогон предыдущего пуша @@ -185,7 +158,7 @@ jobs: } echo "### Предполётные проверки" >> "$GITHUB_STEP_SUMMARY" check "документация" "$DOCS" - check "process.yml и mutation-gate.yml в main и dev" "$WORKFLOW_SYNC" + check "process.yml в main и dev" "$WORKFLOW_SYNC" check "провенанс коммитов" "$PROVENANCE" check "процессный гейт" "$PROCESS_GATE" exit $fail @@ -209,11 +182,6 @@ 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` — от какого коммита @@ -230,15 +198,6 @@ 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: @@ -278,7 +237,7 @@ jobs: run: | if [ "$REF" = "refs/heads/dev" ]; then echo "dev: без фильтров, всё true" - node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT" + printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT" exit 0 fi zero=$(printf '%040d' 0) @@ -302,7 +261,7 @@ jobs: echo "\`before\` (=$BEFORE_SHA) переписан force-push'ем: диапазон недоказуем," echo "все тяжёлые job запускаются без фильтра путей (#347)." } >> "$GITHUB_STEP_SUMMARY" - node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT" + printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT" exit 0 fi # Диапазон считается от последнего ДОКАЗАННО зелёного предка (#387), @@ -312,14 +271,17 @@ jobs: if [ "$EVENT_NAME" = "pull_request" ]; then base="$BASE_SHA"; fi if [ -z "$base" ]; then echo "база не определена — полный прогон без классификации" - node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT" + printf 'frontend=true\nbackend=true\nintegration=true\n' >> "$GITHUB_OUTPUT" exit 0 fi files=$(git diff --name-only "$base" "$HEAD_SHA") printf '%s\n' "$files" | head -50 - # Шаблоны — в scripts/classify-changes.mjs: там они проверяемы - # unit-тестом, здесь — только переписываются в выходы (#473 AC8). - printf '%s\n' "$files" | node scripts/classify-changes.mjs | tee -a "$GITHUB_OUTPUT" + 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" # Переиспользование результата тяжёлой job (#208). Ключ = входы поведения # (sourceFingerprint: src/**, demo/fixtures, demo/golden/*.mjs, манифесты @@ -336,10 +298,6 @@ jobs: # заведомо новые и полный набор гейтов перед бетой и релизом идёт всегда. reuse: name: "Переиспользование: это дерево уже проверено" - # `changes` нужна ради набора профилей перф-смока (#473): ключ - # performance_smoke включает набор, иначе glow-only результат засчитался бы - # прогону, которому нужен изометрический профиль. - needs: changes runs-on: ubuntu-latest outputs: smoke: ${{ steps.probe.outputs.smoke }} @@ -349,7 +307,6 @@ 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 @@ -357,21 +314,12 @@ 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 @@ -393,7 +341,7 @@ jobs: uses: actions/cache/restore@v6 with: path: .reuse-marker - key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }}-${{ steps.keys.outputs.performance_smoke_set }} + key: reuse-performance_smoke-${{ steps.keys.outputs.performance_smoke }} lookup-only: true - name: Маркер backend id: m_backend @@ -538,8 +486,8 @@ jobs: 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' + needs: [frontend, reuse] + if: needs.reuse.outputs.smoke != 'true' runs-on: ubuntu-latest timeout-minutes: 20 # Смоки шардируются: последовательный прогон занимал ~7.5 минут и был @@ -642,8 +590,8 @@ jobs: # частично прогнанная матрица не имеет права выглядеть как выполненная работа. smoke_done: name: "Смоки: все шарды зелёные" - needs: [changes, smoke, reuse] - if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.smoke != 'true' + needs: [smoke, reuse] + if: needs.reuse.outputs.smoke != 'true' runs-on: ubuntu-latest steps: - name: Записать маркер успеха @@ -665,8 +613,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: [changes, frontend, reuse] - if: needs.changes.outputs.heavy == 'true' && needs.reuse.outputs.golden != 'true' + needs: [frontend, reuse] + if: needs.reuse.outputs.golden != 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -763,14 +711,13 @@ jobs: 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' + needs: [frontend, reuse] + if: needs.reuse.outputs.performance_smoke != 'true' runs-on: ubuntu-latest # 15 минут не хватало, когда установка браузера шла через apt: замер # начинался на исходе окна (#206). Запас на холодный кэш — при попадании - # job укладывается в те же минуты, что и раньше. Диффозависимые профили - # (#473) добавляют до двух замеров по 3 образца — окно расширено под них. - timeout-minutes: 30 + # job укладывается в те же минуты, что и раньше. + timeout-minutes: 20 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 @@ -810,20 +757,6 @@ 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) @@ -851,7 +784,7 @@ jobs: continue-on-error: true with: path: .reuse-marker - key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }}-${{ needs.reuse.outputs.performance_smoke_set }} + key: reuse-performance_smoke-${{ needs.reuse.outputs.performance_smoke_key }} backend: name: "Бэкенд: pytest в Home Assistant" From 7f029f768d158423a3b4ba1f36a37cca9f817e68 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 00:09:54 +0300 Subject: [PATCH 4/6] =?UTF-8?q?ci:=20=D0=B7=D0=B5=D1=80=D0=BA=D0=B0=D0=BB?= =?UTF-8?q?=D0=BE=20process.yml=20=D0=B8=D0=B7=20dev=20=E2=80=94=20=D0=B8?= =?UTF-8?q?=D0=B4=D0=B5=D0=BC=D0=BF=D0=BE=D1=82=D0=B5=D0=BD=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=BE=D0=BB=D0=BB?= =?UTF-8?q?=D0=B5=D1=80=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20(#499)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Конвейер исполняется из ветки по умолчанию; preflight требует совпадения main = dev. Содержимое — коммит 24c1b723 в dev. Issue: #499 User-Visible: no --- .github/workflows/process.yml | 118 ++++++++++++++++++++++++++++------ 1 file changed, 98 insertions(+), 20 deletions(-) diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index af6dc9b6..499b2898 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -21,10 +21,12 @@ on: issues: types: [labeled] -concurrency: - # Два события по одному issue не должны запускать два прогона. - group: process-issue-${{ github.event.issue.number }} - cancel-in-progress: false +# Concurrency стоит на job, а не на workflow (#499). На уровне workflow в +# группу issue попадал КАЖДЫЙ прогон — и от `polish`, и от `P2`, и от метки, +# которую переставил сам конвейер. GitHub держит в группе один идущий и один +# ожидающий прогон, и новый ожидающий вытесняет старого: ожидавший S7-code-review +# отменялся первой же посторонней меткой. Теперь посторонняя метка не запускает +# ни одной job (`if` на guard) и в группу не входит. permissions: contents: read @@ -36,7 +38,13 @@ permissions: jobs: guard: name: "Страж: ребейз на dev и предпосылки ревью" + # Только статусные метки этапов ревью запускают конвейер (#499). Остальные + # события помечаются skipped и не занимают место в группе concurrency. + if: github.event.label.name == 'S4-spec-review' || github.event.label.name == 'S7-code-review' runs-on: ubuntu-latest + concurrency: + group: process-issue-${{ github.event.issue.number }} + cancel-in-progress: false outputs: stage: ${{ steps.decide.outputs.stage }} cycle: ${{ steps.decide.outputs.cycle }} @@ -57,12 +65,20 @@ jobs: GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} REPO: ${{ github.repository }} LABEL: ${{ github.event.label.name }} - BLOCKED: ${{ contains(github.event.issue.labels.*.name, 'blocked') }} - EXHAUSTED: ${{ contains(github.event.issue.labels.*.name, 'review-4') }} - SMALL: ${{ contains(github.event.issue.labels.*.name, 'small') }} - TRIVIAL: ${{ contains(github.event.issue.labels.*.name, 'trivial') }} NUM: ${{ github.event.issue.number }} run: | + # Контроллер идемпотентен (#499): событие только будит его, а состояние + # читается ТЕКУЩЕЕ, не из снимка события. Прогон мог простоять в + # очереди concurrency, пока владелец снял метку или поставил blocked — + # снимок события об этом не знает, а исполнять отозванный запрос нельзя. + current=$(gh issue view "$NUM" --repo "$REPO" --json labels --jq '.labels[].name') + has() { printf '%s\n' "$current" | grep -qx -- "$1"; } + BLOCKED=false; EXHAUSTED=false; SMALL=false; TRIVIAL=false + has blocked && BLOCKED=true + has review-4 && EXHAUSTED=true + has small && SMALL=true + has trivial && TRIVIAL=true + # Этап определяется первым: от него зависит, какие вердикты считать. stage=""; marker="" case "$LABEL" in @@ -70,6 +86,13 @@ jobs: S7-code-review) stage="code"; marker="CODE-REVIEW" ;; *) echo "метка $LABEL конвейер не запускает" ;; esac + # Метка, породившая событие, уже снята — запрос отозван. Это не отказ + # и не повод для комментария: владелец передумал сам, шум ему не нужен. + if [ -n "$stage" ] && ! has "$LABEL"; then + echo "метка $LABEL уже снята с issue — запрос отозван, конвейер не запускается (#499)" + echo "запрос отозван: \`$LABEL\` снята до старта" >> "$GITHUB_STEP_SUMMARY" + stage="" + fi # Лимит циклов: 4 обычный, 2 на лёгком и коротком треке (PROCESS.md §4). limit=4 @@ -236,6 +259,9 @@ jobs: needs: guard if: needs.guard.outputs.stage != '' runs-on: ubuntu-latest + concurrency: + group: process-issue-${{ github.event.issue.number }} + cancel-in-progress: false # Время — единственный настоящий ограничитель зациклившегося прогона. timeout-minutes: 45 steps: @@ -391,6 +417,28 @@ jobs: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" echo "материал ревью: $(git rev-parse --short HEAD)" + # Повторное применение зелёного вердикта без вызова модели (#499). Сценарий + # #437 r4: зелёный r3 не слился (страж #312), задача вернулась в S6 и тут же + # в S7, и ревьюер двенадцать минут заново разбирал дерево, в котором с r3 + # изменился ровно один файл — его собственный документ r3. Правило узкое: + # последний документ этапа несёт записанный конвейером вердикт `green` + # с High 0, и `git diff` между его якорем-деревом и HEAD пуст вне + # docs/reviews/**. Любое иное отличие — ребейз, тест, фикстура, скрипт, + # ТЗ — даёт полный разбор. Только этап code: материал spec может жить в + # теле issue, которого в дереве нет. + - name: "Зелёный вердикт прошлого захода применим без ревью (#499)" + id: reuse + if: steps.rebase.outputs.conflict != 'true' && needs.guard.outputs.stage == 'code' + env: + NUM: ${{ github.event.issue.number }} + run: | + out=$(node scripts/review-doc-guard.mjs --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD) + printf '%s\n' "$out" + printf '%s\n' "$out" >> "$GITHUB_OUTPUT" + if printf '%s\n' "$out" | grep -qx 'reuse=true'; then + echo "вердикт прошлого захода применяется повторно: модель не вызывается" >> "$GITHUB_STEP_SUMMARY" + fi + # Конфликт возвращает задачу автору ДО ревью. Инвариант «после прогона # метка меняется всегда» при этом держится: возврат в S6-in-progress — # тоже смена метки, и автор не ждёт впустую. @@ -464,21 +512,21 @@ jobs: # Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог # измениться именно в ней, и установка по копии из dev дала бы не то дерево. - name: Установить зависимости - if: steps.rebase.outputs.conflict != 'true' + if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true' run: npm ci # Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте), # но когда нужен — качать его заново дороже, чем держать в кэше. - name: Кэш браузеров Playwright id: pw - if: steps.rebase.outputs.conflict != 'true' + if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true' uses: actions/cache@v6 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} - name: Установить Chromium - if: steps.rebase.outputs.conflict != 'true' && steps.pw.outputs.cache-hit != 'true' + if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true' && steps.pw.outputs.cache-hit != 'true' # Без --with-deps: системные библиотеки Chromium предустановлены в # образе ubuntu-latest, а apt при промахе кэша съедал минуты из бюджета # ревью и подолгу перебирал недоступное azure-зеркало (#175). Если @@ -488,7 +536,7 @@ jobs: - name: Review id: review - if: steps.rebase.outputs.conflict != 'true' + if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true' uses: anthropics/claude-code-action@v1 env: # Вне рабочей копии: восстановление дерева ревьюером не должно @@ -573,7 +621,14 @@ jobs: решаешь по существу в своём вердикте. Для этапа code: материал — диапазон `git log --oneline origin/dev..HEAD` - и `git diff origin/dev...HEAD`. Ручного тестирования в цикле нет, + и `git diff origin/dev...HEAD`. **Материал ревью — ровно + `${{ steps.material.outputs.sha }}`, рабочая копия уже на нём.** Не + делай `git fetch`, `git pull` и `git checkout` на другой коммит: вердикт + привязан к этому SHA (#312), и страж слияния сверяет вершину ветки с + ним. Если автор в issue называет более новый коммит, которого в + материале нет, — это находка «материал не был запушен до метки», а не + повод подтянуть его самому (#437 r3→r4 стоил лишнего раунда именно так, + #499). Ручного тестирования в цикле нет, поэтому именно ты отвечаешь на вопрос «оно вообще работает». По каждому AC: либо он доказан автотестом и ты убедился, что тест умеет падать, либо разобран по коду с явной записью «проверено @@ -704,7 +759,7 @@ jobs: # Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит, # решает этот шаг, а не модель: всё остальное откатывается. - name: Опубликовать документ ревью - if: steps.rebase.outputs.conflict != 'true' + if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true' env: TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} BRANCH: ${{ steps.branch.outputs.name }} @@ -715,7 +770,12 @@ jobs: MATERIAL_SHA: ${{ steps.branch.outputs.sha }} MATERIAL_TREE: ${{ steps.branch.outputs.tree }} MATERIAL_SPECS: ${{ steps.branch.outputs.specs }} + # Вердикт из structured_output попадает в блок якорей (#499): по нему + # следующий заход решает, можно ли применить зелёный вердикт повторно. + OUT: ${{ steps.review.outputs.structured_output }} run: | + verdict=$(printf '%s' "$OUT" | jq -r '.verdict // empty' 2>/dev/null || true) + high=$(printf '%s' "$OUT" | jq -r '.high // empty' 2>/dev/null || true) # Ветки задачи может не быть: у задач, размеченных до появления # конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча # выходил с нулём, и разбор ревью терялся — оставался только вердикт @@ -770,7 +830,8 @@ jobs: # может; блок помечен как машинный, чтобы никто не правил его руками. node scripts/review-doc-guard.mjs --anchor="$doc" \ --sha="$MATERIAL_SHA" --tree="$MATERIAL_TREE" \ - --branch="${BRANCH:-dev}" --specs="$MATERIAL_SPECS" + --branch="${BRANCH:-dev}" --specs="$MATERIAL_SPECS" \ + --verdict="$verdict" --high="$high" else echo "::warning::$SOURCE не найден — документа для публикации нет" fi @@ -860,7 +921,7 @@ jobs: # достижим там из необновлённой локальной ветки. Читателю отчёта от этого # пользы нет — он достанет только то, что есть на origin. - name: "Материал раунда воспроизводим (#413)" - if: steps.rebase.outputs.conflict != 'true' + if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true' env: NUM: ${{ github.event.issue.number }} STAGE: ${{ needs.guard.outputs.stage }} @@ -881,9 +942,26 @@ jobs: env: OUT: ${{ steps.review.outputs.structured_output }} STAGE: ${{ needs.guard.outputs.stage }} + REUSE: ${{ steps.reuse.outputs.reuse }} + REUSE_DOC: ${{ steps.reuse.outputs.doc }} + REUSE_ROUND: ${{ steps.reuse.outputs.round }} + REUSE_TREE: ${{ steps.reuse.outputs.tree }} + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + NUM: ${{ github.event.issue.number }} run: | - verdict=$(echo "$OUT" | jq -r '.verdict') - high=$(echo "$OUT" | jq -r '.high') + if [ "$REUSE" = "true" ]; then + # Модель не вызывалась: вердикт — записанный конвейером зелёный + # прошлого захода, дерево вне docs/reviews с тех пор не менялось (#499). + verdict=green; high=0 + short_tree=$(printf '%s' "$REUSE_TREE" | cut -c1-12) + gh issue comment "$NUM" --repo "${{ github.repository }}" --body \ + "Вердикт: зелёный · заход r${{ needs.guard.outputs.cycle }} · применён повторно из r${REUSE_ROUND} без вызова модели (#499) · High: 0 · Medium: 0 · Документ: docs/reviews/${REUSE_DOC} + + Дерево материала \`${short_tree}\` с захода r${REUSE_ROUND} не изменилось ни в одном файле вне \`docs/reviews/\` (проверено \`git diff\` по содержимому). Новый документ не публикуется: разбирать нечего. Любое отличие дерева — ребейз, тест, фикстура, ТЗ — запустило бы полный разбор." + else + verdict=$(echo "$OUT" | jq -r '.verdict') + high=$(echo "$OUT" | jq -r '.high') + fi echo "вердикт: $verdict, High: $high" # Вперёд двигает ТОЛЬКО зелёный. Жёлтый и красный возвращают @@ -975,9 +1053,9 @@ jobs: cat > /tmp/stale-verdict.md < Date: Wed, 9 Sep 2026 00:57:05 +0300 Subject: [PATCH 5/6] =?UTF-8?q?ci:=20=D0=B7=D0=B5=D1=80=D0=BA=D0=B0=D0=BB?= =?UTF-8?q?=D0=BE=20process.yml=20=D0=B8=20nightly.yml=20=D0=B8=D0=B7=20de?= =?UTF-8?q?v=20=E2=80=94=20=D1=82=D0=BE=D1=87=D0=BD=D1=8B=D0=B9=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=BD=D0=B4=D0=B8=D0=B4=D0=B0=D1=82=20=D0=B8=20=D0=BE?= =?UTF-8?q?=D0=B6=D0=B8=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20Validate=20(#492)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: #492 User-Visible: no --- .github/workflows/nightly.yml | 30 +++++++++++++- .github/workflows/process.yml | 74 +++++++---------------------------- 2 files changed, 43 insertions(+), 61 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index ca121e11..f52efc46 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -13,6 +13,11 @@ # Красный ночной прогон — сигнал автору последних коммитов на dev, не гейт: # гейт беты по-прежнему требует зелёный Validate на точном SHA кандидата, и # там полный набор идёт заново. +# +# Сигнал обязан быть настоящим (#492 §7): до этой задачи job завершалась +# успехом в момент постановки Validate в очередь, и красный полный прогон не +# делал ночной workflow красным. Теперь job находит запущенный прогон и ждёт +# его: успешный dispatch — не успешная проверка. name: Ночной полный прогон dev on: @@ -26,13 +31,34 @@ permissions: jobs: dispatch: - name: "Запустить Validate на dev с полным набором" + name: "Запустить Validate на dev с полным набором и дождаться результата" runs-on: ubuntu-latest + timeout-minutes: 90 steps: - env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} run: | set -euo pipefail + since=$(date -u +%FT%TZ) gh workflow run validate.yml --repo "$REPO" --ref dev -f full=true - echo "Validate(dev, full=true) поставлен в очередь: $(date -u +%FT%TZ)" + echo "Validate(dev, full=true) поставлен в очередь: $since" + # Найти именно этот прогон: workflow_dispatch на dev, созданный не + # раньше момента запуска. До трёх минут на появление в списке. + run_id="" + for _ in $(seq 1 18); do + sleep 10 + run_id=$(gh run list --repo "$REPO" --workflow validate.yml --branch dev \ + --event workflow_dispatch --json databaseId,createdAt --limit 5 \ + --jq "[.[] | select(.createdAt >= \"$since\")] | sort_by(.createdAt) | last | .databaseId // empty") + [ -n "$run_id" ] && break + done + if [ -z "$run_id" ]; then + echo "::error::прогон Validate не появился за 3 минуты — dispatch не равен проверке" + exit 1 + fi + url="${{ github.server_url }}/$REPO/actions/runs/$run_id" + echo "дочерний прогон: $url" + echo "- Validate(dev, full=true): $url" >> "$GITHUB_STEP_SUMMARY" + # Ждём завершения; красный дочерний прогон — красный ночной. + gh run watch "$run_id" --repo "$REPO" --exit-status --interval 30 diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index 499b2898..19fee6a4 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -1012,16 +1012,19 @@ jobs: # S8-merged утверждает, что код в dev. Значит слияние обязано произойти # ДО метки, иначе она врёт в промежутке. # - # При конфликте шаг НЕ падает и метку не оставляет на месте. Первая - # редакция делала именно так, и это оказалось тупиком: автор ждёт смену - # метки, метка не менялась, и он тридцать раз опрашивал впустую, чтобы - # затем отчитаться «лимит исчерпан» — при зелёном вердикте. Инвариант - # теперь жёстче: ПОСЛЕ ПРОГОНА РЕВЬЮ МЕТКА МЕНЯЕТСЯ ВСЕГДА. + # Слияние — точный кандидат (#492 §4, scripts/merge-candidate.mjs): + # ветка сверяется с материалом (#312); если dev не двигался — push с + # lease на текущую вершину; если двигался — ребейз, сравнение patch-id + # с проверенным диффом, публикация кандидата в ветку, ожидание + # Validate на этом SHA и только потом push в dev с lease. Повторное + # движение dev — новая попытка, не более трёх. Каждый исход, кроме + # успеха, ведёт в S6-in-progress/S7-code-review с комментарием, ПОСЛЕ + # ПРОГОНА МЕТКА МЕНЯЕТСЯ ВСЕГДА — инвариант тот же, что и раньше. - name: Слить ветку в dev id: merge if: needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' env: - TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + HP_PROCESS_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} BRANCH: ${{ steps.branch.outputs.name }} NUM: ${{ github.event.issue.number }} @@ -1032,58 +1035,8 @@ jobs: echo "merged=false" >> "$GITHUB_OUTPUT" exit 0 fi - git fetch -q origin dev "$BRANCH" - # #312: сливается только проверенный код. Допустимые вершины ветки: - # сам SHA материала либо он же плюс ровно один коммит публикации - # документа ревью (дифф только docs/reviews/). Любой другой коммит — - # ветка уехала после ревью, вердикт к ней не применим: возврат в - # S6-in-progress через merged=false, как при конфликте. - actual=$(git rev-parse "origin/$BRANCH") - reviewed="$MATERIAL_SHA" - fresh=false - if [ "$actual" = "$reviewed" ]; then - fresh=true - elif [ "$(git rev-parse "$actual^" 2>/dev/null)" = "$reviewed" ] \ - && [ -z "$(git diff --name-only "$reviewed" "$actual" -- . ':!docs/reviews')" ]; then - fresh=true - fi - if [ "$fresh" != true ]; then - echo "merged=false" >> "$GITHUB_OUTPUT" - echo "::warning::ветка $BRANCH уехала после проверенного SHA $reviewed (сейчас $actual) — слияние отменено (#312)" - cat > /tmp/stale-verdict.md <> "$GITHUB_OUTPUT" - echo "::warning::ветка $BRANCH не сливается в dev без конфликта" - cat > /tmp/conflict.md <> "$GITHUB_OUTPUT" - echo "слито в dev: $(git rev-parse --short HEAD)" + node scripts/merge-candidate.mjs --branch="$BRANCH" --material="$MATERIAL_SHA" \ + --issue="$NUM" --repo="${{ github.repository }}" - name: Переставить метку if: steps.rebase.outputs.conflict != 'true' @@ -1094,7 +1047,10 @@ jobs: FROM: ${{ steps.decide.outputs.from }} # Зелёное код-ревью без слияния ведёт не в S8-merged, а обратно к # автору: метка утверждала бы, что код в dev, а его там нет. - TO: ${{ (needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.merged != 'true') && 'S6-in-progress' || steps.decide.outputs.to }} + # Точный кандидат (#492) сам называет исход: S8 после push, S6/S7 — + # когда кандидат не слит (конфликт, красный Validate, изменившийся + # patch-id, ушедший dev). Без исхода от скрипта — как раньше: S6. + TO: ${{ (needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.to != '') && steps.merge.outputs.to || ((needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.merged != 'true') && 'S6-in-progress' || steps.decide.outputs.to) }} run: | gh issue edit "$NUM" --repo "${{ github.repository }}" \ --add-label "$TO" --remove-label "$FROM" From fff171c7dc3994bd884d95b812bd82b16b69f080 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 9 Sep 2026 02:00:55 +0300 Subject: [PATCH 6/6] =?UTF-8?q?ci:=20mirror=20process.yml=20from=20dev=20(?= =?UTF-8?q?#503=20=E2=80=94=20deterministic=20Claude=20Code=20install)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: #503 User-Visible: no --- .github/workflows/process.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index 19fee6a4..c6f3fed0 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -534,6 +534,29 @@ jobs: # внятной ошибкой — тогда флаг вернуть. run: npx playwright install chromium + # Action ревью ставит Claude Code через `claude install`, и с его + # v1.0.218 (Claude Code 2.1.265) лаунчер ~/.local/bin/claude на + # ubuntu-latest иногда не появляется, хотя установщик рапортует об успехе; + # action верит рапорту и падает на ENOENT (anthropics, issue 1817). + # Кладём бинарник сами: версию берём ту, что пинит сам action (он уже + # скачан в _actions к началу job), контрольную сумму — из манифеста релиза. + - name: Установить Claude Code детерминированно + id: claude_bin + if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true' + run: | + src=$(ls "$RUNNER_WORKSPACE"/../_actions/anthropics/claude-code-*/v1/src/entrypoints/run.ts 2>/dev/null | head -1) + ver=$(grep -oE 'claudeCodeVersion = "[0-9]+\.[0-9]+\.[0-9]+"' "$src" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || true) + ver="${ver:-2.1.265}" + base=https://downloads.claude.ai/claude-code-releases + bin="$HOME/.local/bin/claude" + mkdir -p "$(dirname "$bin")" + curl -fsSL --retry 3 "$base/$ver/linux-x64/claude" -o "$bin" + sum=$(curl -fsSL --retry 3 "$base/$ver/manifest.json" | jq -r '.platforms["linux-x64"].checksum') + echo "$sum $bin" | sha256sum -c - + chmod +x "$bin" + "$bin" --version + echo "path=$bin" >> "$GITHUB_OUTPUT" + - name: Review id: review if: steps.rebase.outputs.conflict != 'true' && steps.reuse.outputs.reuse != 'true' @@ -546,6 +569,7 @@ jobs: # Подписка, а не отдельный счёт API: токен выпускается через # `claude setup-token` (Pro/Max). Действуют лимиты подписки. claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + path_to_claude_code_executable: ${{ steps.claude_bin.outputs.path }} prompt: | Ты ревьюер проекта House Plan. Язык ответа — русский.