From baf283c50f3edc305811e2074690830ef5da9d73 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 10:23:28 +0300 Subject: [PATCH] ci: thin default-branch callers invoke reusable bodies at @dev (#623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six workflows run from the default branch (issues, schedule, workflow_run): process, process-resume, process-reconcile, mutation-gate, nightly, process-metrics. Their bodies move to _.yml (on: workflow_call); the original files keep only triggers, run-name, permissions, concurrency and one job `uses: Matysh/houseplan-card/.github/workflows/_.yml@dev` with `secrets: inherit`. A pipeline change becomes one commit to dev. - caller job permissions = union of body job permissions (#556 minimum kept per job inside the body); caller `if` repeats the body guard for process and process-resume so unrelated events stay skipped; - dispatch inputs forwarded via workflow_call inputs of the same names; - _mutation-gate.yml keys evidence/marker on job.workflow_sha (the body SHA): in a called workflow github.workflow_sha belongs to the caller in main; - action-pins: narrow exception for this repo's _*.yml at @dev with a reason; - preflight workflow_sync compares all six thin callers (was 3 of 6); performance.yml excluded: its schedule judges main with main's own body; - tests read bodies from _*.yml; new test/default-branch-workflows.test.mjs; six mutants; PROCESS.md §10.4, AGENTS.md, REVIEWER.md updated. Issue: #623 User-Visible: no --- .github/workflows/_mutation-gate.yml | 353 +++++ .github/workflows/_nightly.yml | 64 + .github/workflows/_process-metrics.yml | 59 + .github/workflows/_process-reconcile.yml | 57 + .github/workflows/_process-resume.yml | 60 + .github/workflows/_process.yml | 1648 ++++++++++++++++++++++ .github/workflows/mutation-gate.yml | 340 +---- .github/workflows/nightly.yml | 69 +- .github/workflows/process-metrics.yml | 58 +- .github/workflows/process-reconcile.yml | 58 +- .github/workflows/process-resume.yml | 63 +- .github/workflows/process.yml | 1645 +-------------------- .github/workflows/validate.yml | 30 +- AGENTS.md | 12 + PROCESS.md | 27 +- docs/process/REVIEWER.md | 2 +- scripts/action-pins.mjs | 17 + scripts/mutation-registry.mjs | 87 +- test/action-pins.test.mjs | 31 +- test/default-branch-workflows.test.mjs | 228 +++ test/mutation-gate.test.mjs | 24 +- test/nightly-workflow.test.mjs | 4 +- test/process-digests.test.mjs | 2 +- test/process-metrics.test.mjs | 9 +- test/process-resume.test.mjs | 11 +- test/rebase-generated.test.mjs | 2 +- test/review-doc-guard.test.mjs | 32 +- test/review-result-gate.test.mjs | 2 +- test/reviews-index.test.mjs | 2 +- test/validate-workflow.test.mjs | 5 +- 30 files changed, 2805 insertions(+), 2196 deletions(-) create mode 100644 .github/workflows/_mutation-gate.yml create mode 100644 .github/workflows/_nightly.yml create mode 100644 .github/workflows/_process-metrics.yml create mode 100644 .github/workflows/_process-reconcile.yml create mode 100644 .github/workflows/_process-resume.yml create mode 100644 .github/workflows/_process.yml create mode 100644 test/default-branch-workflows.test.mjs diff --git a/.github/workflows/_mutation-gate.yml b/.github/workflows/_mutation-gate.yml new file mode 100644 index 00000000..64189d81 --- /dev/null +++ b/.github/workflows/_mutation-gate.yml @@ -0,0 +1,353 @@ +name: "Мутационный гейт · тело (#623)" + +# Реестр известных поломок (issue #85): каждый мутант ломает продуктовый код +# известным способом, и объявленный тест ОБЯЗАН на этом покраснеть. Тест, +# оставшийся зелёным на сломанном коде, ничего не защищает — он лишь выглядит +# защитой, и это хуже его отсутствия. +# +# Прогон дорогой и проверяет не продукт, а тесты, поэтому он не входит ни в +# Validate, ни в цикл разработки, ни в релизный гейт (#513, решение владельца +# 09.09): каждую ночь по расписанию, отказ — issue с отчётом (#472). +# Мутанты, задетые диффом, конвейер ревью и слияние гоняют отдельно на своём +# кандидате (#510); кандидат беты, `full=true` и ночной Validate их не +# запрашивают (#601) — ночью достаточно этого полного реестра. +# Дешёвая половина — «якоря патчей живы, guard-файлы существуют» — идёт с +# обычными юнитами: test/mutation-gate.test.mjs. +# +# #332: бандл собирается только мутантам с браузерным гвардом (guardNeedsBundle), +# компиляция тестов в worktree стартует с тёплого test-build (инкрементальный +# tsc), а реестр режется на четыре чересполосных шарда — полный прогон +# укладывается в десятки минут вместо часов. Локальный дифф-режим: +# node scripts/mutation-gate.mjs --changed origin/dev..HEAD +# +# #620: ночь по расписанию на дереве, уже доказанном зелёным полным прогоном +# (тот же tree материала, тот же SHA workflow, маркер не старше недели), шарды не +# гоняет — в сводке «reused from run N». Маркер пишет только зелёный агрегатор, +# поэтому красный не переносится: следующая ночь гонит реестр заново и снова +# заводит issue (#472). Ручной dispatch гонит полный реестр всегда. Решение — +# чистая функция scripts/mutation-nightly-reuse.mjs. +# +# #623: «SHA workflow» — `job.workflow_sha`, SHA этого файла-тела. В вызываемом +# workflow `github.workflow_sha` принадлежит вызывающему `mutation-gate.yml` из +# main и не меняется вместе с телом, поэтому здесь он не используется. + +on: + # #623: тело вызывается тонким файлом `mutation-gate.yml` из ветки по умолчанию + # по ссылке `@dev`; триггеры, run-name и concurrency живут там. + workflow_call: + inputs: + ref: + description: "Git ref whose mutation guards must be proved" + required: false + type: string + default: dev + +permissions: + contents: read + +# Concurrency уровня workflow — у вызывающего `mutation-gate.yml` (#623). + +jobs: + # #549: moving ref разрешается ровно один раз. Все шарды ниже получают один + # commit/tree, а не самостоятельно читают dev в разное время. + material: + name: "Зафиксировать неизменяемый материал" + runs-on: ubuntu-latest + outputs: + sha: ${{ steps.identity.outputs.sha }} + tree: ${{ steps.identity.outputs.tree }} + ref: ${{ steps.identity.outputs.ref }} + reuse: ${{ steps.reuse.outputs.reuse }} + reused_run: ${{ steps.reuse.outputs.reused_run }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }} + fetch-depth: 0 + - name: Зафиксировать commit и tree + id: identity + run: | + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT" + echo "ref=${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}" >> "$GITHUB_OUTPUT" + # #620: самый свежий маркер зелёного прогона этого tree и этого workflow. + # Ключ уникален на прогон (кэш неизменяем), восстанавливается по префиксу — + # иначе перепроверка после недели не смогла бы освежить маркер. + - name: Маркер зелёного прогона этого дерева + if: github.event_name == 'schedule' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: artifacts/mutation-green + key: mutation-green-v1-${{ steps.identity.outputs.tree }}-${{ job.workflow_sha }}-${{ github.run_id }} + restore-keys: | + mutation-green-v1-${{ steps.identity.outputs.tree }}-${{ job.workflow_sha }}- + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + # Любой сбой решения — полный прогон: пропуск должен быть доказан, а не + # выведен из ошибки. Материал без скрипта (dispatch старого ref) — тоже. + - name: Нужен ли прогон + id: reuse + env: + EVENT_NAME: ${{ github.event_name }} + TREE: ${{ steps.identity.outputs.tree }} + WORKFLOW_SHA: ${{ job.workflow_sha }} + RUN_URL_BASE: ${{ github.server_url }}/${{ github.repository }}/actions/runs + run: | + decision="$RUNNER_TEMP/mutation-reuse.txt" + if [ -f scripts/mutation-nightly-reuse.mjs ] && node scripts/mutation-nightly-reuse.mjs --decide \ + --event="$EVENT_NAME" --tree="$TREE" --workflow-sha="$WORKFLOW_SHA" \ + --marker=artifacts/mutation-green/marker.json \ + --summary="$GITHUB_STEP_SUMMARY" --run-url-base="$RUN_URL_BASE" > "$decision"; then + cat "$decision" >> "$GITHUB_OUTPUT" + if grep -qx 'reuse=true' "$decision"; then + echo "::notice::reused from run $(sed -n 's/^reused_run=//p' "$decision")" + fi + else + echo "::warning::решение о повторном использовании не получено — полный прогон" + echo "reuse=false" >> "$GITHUB_OUTPUT" + fi + + mutants: + name: "Мутанты: каждый обязан красить тесты (шард ${{ matrix.shard }} из 6)" + needs: material + if: needs.material.outputs.reuse != 'true' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4, 5, 6] + # Шесть чересполосных шардов (#604): при четырёх шард нёс ~203 мутанта из + # 810 и рос с реестром — 42 мин 10.09, 57 мин 20.09, 61 мин 21.09, и шард + # 2/4 был снят по потолку без единого FAIL. Делитель тот же, что у + # `changed_mutants` в Validate; число шардов повторяется в `--shard=i/6`, + # `--shards=6` и имени job — тест `mutation-gate.test.mjs` держит их + # равными. Бандл собирают только браузерные гварды. Час — потолок против + # зависшего Chromium, а не бюджет шарда: шард, упёршийся в него, — сигнал + # снова делить, и отчёт (#472) называет такой шард прерванным, не «ok». + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.material.outputs.sha }} + fetch-depth: 0 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: '3.14' + + - name: Установить backend test dependencies + run: | + # Точные версии — в tests_backend/requirements.txt (#392): без них + # резолвер выбирает набор сам, и «зелёный backend» значит разное + # в разные дни. + pip install -r tests_backend/requirements.txt + pip list --format=columns | grep -Ei 'homeassistant|voluptuous|^pytest ' + + - name: Кэш браузеров Playwright + id: pw + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + + - name: Установить Chromium + if: steps.pw.outputs.cache-hit != 'true' + run: npx playwright install --with-deps chromium + + - name: Реестр применим к текущему коду + run: node scripts/mutation-gate.mjs --check + + - name: Тёплый test-build для инкрементальной компиляции мутантов + run: npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs + + # Вывод шарда сохраняется артефактом (#472): строки + # `FAIL : тест остался зелёным…` — единственное место, где названо, + # ЧТО сбежало. Без артефакта отказ безымянный. `PIPESTATUS` — чтобы + # `tee` не съел код выхода раннера. + - name: Каждый тест ловит свою поломку + id: gate + run: | + mkdir -p artifacts/mutation-shard-${{ matrix.shard }} + set -o pipefail + node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/6 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}/mutation-shard-${{ matrix.shard }}.log + # Исход шага едет в evidence (#604): снятый по timeout-minutes шаг даёт + # `cancelled`, и агрегатор отвергает такой шард как неполный — лог без + # строк FAIL сам по себе зелёным не считается. + - name: Записать identity шарда + if: always() + run: | + node scripts/mutation-gate-report.mjs \ + --write-evidence=artifacts/mutation-shard-${{ matrix.shard }}/evidence.json \ + --sha=${{ needs.material.outputs.sha }} \ + --tree=${{ needs.material.outputs.tree }} \ + --workflow-sha=${{ job.workflow_sha }} \ + --run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \ + --shard=${{ matrix.shard }} --shards=6 \ + --outcome=${{ steps.gate.outcome }} + - name: Сохранить лог и identity шарда + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: mutation-shard-${{ matrix.shard }}-attempt-${{ github.run_attempt }} + path: artifacts/mutation-shard-${{ matrix.shard }} + if-no-files-found: warn + retention-days: 30 + + # Результат нельзя приписывать material, пока не доказаны все шесть шардов — + # каждый с identity и с дошедшим до конца прогоном (#549, #604). + # always() нужен при красном мутанте: лог красного шарда всё равно evidence. + evidence: + name: "Доказать единый material всех шардов" + needs: [material, mutants] + if: always() && needs.material.outputs.reuse != 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + # CLI отчётчика принадлежит зафиксированному material. В main лежит + # только workflow и может не быть dev-версии scripts/**. + ref: ${{ needs.material.outputs.sha }} + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + - name: Забрать evidence всех попыток + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + pattern: mutation-shard-* + path: artifacts/mutation-logs + - name: Проверить полноту и identity + run: | + node scripts/mutation-gate-report.mjs --verify-only \ + --logs=artifacts/mutation-logs --shards=6 \ + --sha=${{ needs.material.outputs.sha }} \ + --tree=${{ needs.material.outputs.tree }} \ + --workflow-sha=${{ job.workflow_sha }} \ + --run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} + + # #620: маркер пишется ТОЛЬКО после зелёного агрегатора — все шесть шардов + # доказаны на одном material. Красный или неполный прогон маркера не оставляет, + # и следующая ночь гонит реестр заново. + green_marker: + name: "Записать маркер зелёного прогона" + needs: [material, mutants, evidence] + if: needs.material.outputs.reuse != 'true' && needs.mutants.result == 'success' && needs.evidence.result == 'success' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.material.outputs.sha }} + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + - name: Маркер + run: | + node scripts/mutation-nightly-reuse.mjs \ + --write-marker=artifacts/mutation-green/marker.json \ + --tree=${{ needs.material.outputs.tree }} \ + --sha=${{ needs.material.outputs.sha }} \ + --workflow-sha=${{ job.workflow_sha }} \ + --run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \ + --event=${{ github.event_name }} + - name: Сохранить маркер + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: artifacts/mutation-green + key: mutation-green-v1-${{ needs.material.outputs.tree }}-${{ job.workflow_sha }}-${{ github.run_id }} + + # Адресат у отказа (#472). Только по расписанию: ручной dispatch остаётся + # для отладки самого гейта, его результат смотрят в прогоне — issue на + # каждый такой отказ был бы шумом, который снова перестанут читать. + # + # Права job-уровня ЗАМЕНЯЮТ права workflow, а не дополняют (прецедент — + # validate.yml, job с actions: read): перечислены все три. + report: + name: "Отказ расписания: issue и Telegram" + needs: [material, mutants, evidence] + # #620: ночь, принявшая доказательство прошлого зелёного прогона, отказом не + # является — её шарды пропущены намеренно. Любой другой пропуск шардов + # (упал material) по-прежнему заводит issue. + if: always() && github.event_name == 'schedule' && needs.material.outputs.reuse != 'true' && (needs.mutants.result != 'success' || needs.evidence.result != 'success') + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + issues: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + # Не перечитываем moving dev: код отчётчика берётся из уже + # зафиксированного material. В стабильном main dev-скриптов нет. + ref: ${{ needs.material.outputs.sha }} + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + - name: Забрать логи шардов + continue-on-error: true + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # 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: | + mkdir -p artifacts + node scripts/mutation-gate-report.mjs \ + --require-evidence --logs=artifacts/mutation-logs --shards=6 \ + --sha=${{ needs.material.outputs.sha }} \ + --tree=${{ needs.material.outputs.tree }} \ + --workflow-sha=${{ job.workflow_sha }} \ + --run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \ + --run-url="$RUN_URL" --ref=${{ needs.material.outputs.ref }} \ + --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 diff --git a/.github/workflows/_nightly.yml b/.github/workflows/_nightly.yml new file mode 100644 index 00000000..10dfb655 --- /dev/null +++ b/.github/workflows/_nightly.yml @@ -0,0 +1,64 @@ +# Ночной полный прогон (#479). +# +# Тяжёлые job Validate — смоки, golden, performance_smoke — на обычном пуше не +# идут: они ни разу не ловили дефект в момент ревью и стоили ~6 минут +# критического пути на каждую итерацию. Полный набор идёт на кандидате беты +# (трейлер `Release:`), по кнопке и здесь — каждую ночь на голове `dev`. +# +# Почему не `schedule` прямо в validate.yml: расписание исполняется на ветке +# по умолчанию (`main`), а проверять надо `dev`. Один dispatch с `--ref dev` +# делает это без переписывания checkout во всех job. Reuse (#208) сохраняется: +# при неизменённом дереве ночной прогон обойдётся маркерами. +# +# Красный ночной прогон — сигнал автору последних коммитов на dev, не гейт: +# гейт беты по-прежнему требует зелёный Validate на точном SHA кандидата, и +# там полный набор идёт заново. +# +# Сигнал обязан быть настоящим (#492 §7): до этой задачи job завершалась +# успехом в момент постановки Validate в очередь, и красный полный прогон не +# делал ночной workflow красным. Теперь job находит запущенный прогон и ждёт +# его: успешный dispatch — не успешная проверка. +name: "Ночной полный прогон dev · тело (#623)" + +on: + # #623: тело вызывается тонким файлом `nightly.yml` из ветки по умолчанию + # по ссылке `@dev`; триггеры, run-name и concurrency живут там. + workflow_call: + +permissions: + actions: write + contents: read + +jobs: + dispatch: + 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) поставлен в очередь: $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-metrics.yml b/.github/workflows/_process-metrics.yml new file mode 100644 index 00000000..80c9c416 --- /dev/null +++ b/.github/workflows/_process-metrics.yml @@ -0,0 +1,59 @@ +name: "Метрики процесса · тело (#623)" + +# #637: еженедельный замер вместо ощущений — lead time S→S7→S8, раунды ревью, +# прогоны Validate по исходам, минуты конвейера в S4/S7. Только чтение: отчёт +# идёт в step summary и артефакт; в issue и репозиторий ничего не пишется. +# Цифры аудита 22.09 (1,57 раунда код-ревью, S7 ≈ 36 мин/раунд, 226 Validate +# за неделю) были собраны руками за час — теперь они стоят один запуск. + +on: + # #623: тело вызывается тонким файлом `process-metrics.yml` из ветки по умолчанию + # по ссылке `@dev`; триггеры, run-name и concurrency живут там. + workflow_call: + inputs: + days: + description: "Окно в днях" + required: false + type: string + default: "7" + +permissions: + contents: read + actions: read + issues: read + +jobs: + metrics: + name: "Снимок недели: issue, раунды, прогоны" + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + # Код — из dev, как у reconcile: расписание читается из main, а исполняется + # версия, которую проверил CI. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: dev + fetch-depth: 1 + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + - name: Собрать отчёт + env: + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + REPO: ${{ github.repository }} + DAYS: ${{ inputs.days || '7' }} + run: | + mkdir -p artifacts/process-metrics + node scripts/process-metrics.mjs --repo="$REPO" --days="$DAYS" \ + --output=artifacts/process-metrics/report.md \ + --json=artifacts/process-metrics/report.json > /dev/null + cat artifacts/process-metrics/report.md >> "$GITHUB_STEP_SUMMARY" + - name: Сохранить отчёт + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: process-metrics-${{ github.run_id }} + path: artifacts/process-metrics + if-no-files-found: error + retention-days: 90 diff --git a/.github/workflows/_process-reconcile.yml b/.github/workflows/_process-reconcile.yml new file mode 100644 index 00000000..677b01c1 --- /dev/null +++ b/.github/workflows/_process-reconcile.yml @@ -0,0 +1,57 @@ +name: "Сверка очереди ревью · тело (#623)" + +on: + # #623: тело вызывается тонким файлом `process-reconcile.yml` из ветки по умолчанию + # по ссылке `@dev`; триггеры, run-name и concurrency живут там. + workflow_call: + inputs: + apply: + description: "Повторно будить потерянные запросы и публиковать диагностику" + required: false + type: boolean + default: true + +permissions: + actions: read + contents: read + issues: read + +jobs: + reconcile: + name: "Один снимок S4/S7 без polling модели" + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: process-reconcile + cancel-in-progress: false + steps: + # Расписание читается из main, а исполняемый reconciler — из dev: так + # после штатного merge действует та же версия кода, которую проверил CI. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: dev + fetch-depth: 1 + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + - name: Сопоставить labels, requests, runs и sealed evidence + env: + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + REPO: ${{ github.repository }} + APPLY: ${{ github.event_name == 'schedule' || inputs.apply == true }} + run: | + mkdir -p artifacts/process-reconcile + node scripts/process-reconcile.mjs \ + --repo "$REPO" \ + --apply="$APPLY" \ + --max-actions=5 \ + --output=artifacts/process-reconcile/summary.json + - name: Опубликовать компактный machine-readable итог + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: process-reconcile-${{ github.run_id }}-${{ github.run_attempt }} + path: artifacts/process-reconcile/summary.json + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/_process-resume.yml b/.github/workflows/_process-resume.yml new file mode 100644 index 00000000..ef125f8c --- /dev/null +++ b/.github/workflows/_process-resume.yml @@ -0,0 +1,60 @@ +name: "Продолжение ревью после Validate · тело (#623)" + +# #636. Стадия `prepare` конвейера (process.yml) больше не ждёт Validate с +# мутантами на материале внутри job — раннер спал ≈ 28 минут на раунд при +# 10–12 минутах работы модели. Она диспатчит прогон, кладёт запечатанный +# маркер `review-pending-…` и выходит. Этот workflow просыпается на завершение +# любого Validate и, если раунд ждал именно этот прогон (маркер на материале, +# метка S7 стоит, активного прогона конвейера нет), переставляет метку S7 — +# новый прогон `prepare` находит завершённый dispatch и продолжает раунд. +# Ничего не оценивает: зелёный/красный разбирает сам конвейер. Страховка на +# потерянное событие — process-reconcile.yml с тем же маркером. +# +# Для события `workflow_run` GitHub берёт workflow только из ветки по +# умолчанию (main). Там лежит тонкий `process-resume.yml`, который вызывает +# этот файл по ссылке `@dev` (#623); сверка тонких копий — в preflight +# validate.yml. + +on: + # #623: тело вызывается тонким файлом `process-resume.yml` из ветки по умолчанию + # по ссылке `@dev`; триггеры, run-name и concurrency живут там. + workflow_call: + +permissions: + contents: read + actions: read + +jobs: + resume: + name: "Разбудить раунд, ждавший этот Validate" + if: github.event.workflow_run.event == 'workflow_dispatch' && startsWith(github.event.workflow_run.head_branch, 'issue/') + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: process-resume-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + steps: + # Код берётся из dev, как у reconcile: после штатного слияния действует + # версия, которую проверил CI, а не копия из main. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: dev + fetch-depth: 1 + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + # Метка переставляется HP_PROCESS_TOKEN: событие от GITHUB_TOKEN не + # запустило бы process.yml (см. шапку process.yml, п. 1). + - name: Решить по маркеру ожидания и переставить S7 + env: + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + REPO: ${{ github.repository }} + BRANCH: ${{ github.event.workflow_run.head_branch }} + SHA: ${{ github.event.workflow_run.head_sha }} + EVENT: ${{ github.event.workflow_run.event }} + STATUS: ${{ github.event.workflow_run.status }} + run: | + node scripts/process-resume.mjs \ + --repo="$REPO" --branch="$BRANCH" --sha="$SHA" \ + --event="$EVENT" --status="$STATUS" --apply=true | tee -a "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/_process.yml b/.github/workflows/_process.yml new file mode 100644 index 00000000..dbe7457a --- /dev/null +++ b/.github/workflows/_process.yml @@ -0,0 +1,1648 @@ +name: "Ревью-конвейер · тело (#623)" + +# Событийный конвейер процесса (PROCESS.md). Смена статусной метки — это +# сообщение: она порождает событие, событие запускает следующий шаг. +# +# S4-spec-review -> ревью ТЗ -> S5-ready | S3-spec +# S7-code-review -> код-ревью -> слияние в dev -> S8-merged | S6-in-progress +# +# Три вещи, без которых конвейер молча не работает: +# +# 1. Метки переставляются токеном HP_PROCESS_TOKEN, а не GITHUB_TOKEN. GitHub +# намеренно не запускает workflow от событий, вызванных GITHUB_TOKEN, чтобы +# не было циклов — цепочка оборвалась бы после первого шага. +# 2. Для события `issues` GitHub берёт workflow только из ветки по умолчанию +# (main). Там лежит тонкий `process.yml`, который вызывает этот файл по +# ссылке `@dev` (#623): тело конвейера читается из dev, зеркало в main не +# нужно. Триггер, run-name и потолок прав — в `process.yml`. +# 3. Многострочный текст внутри `run:` — только через heredoc. Строка с нулевым +# отступом обрывает блок YAML, и скрипт обрезается без ошибки парсера. +# Проверять не только YAML, но и каждый `run` через `bash -n`. + +on: + # #623: тело вызывается тонким файлом `process.yml` из ветки по умолчанию + # по ссылке `@dev`; триггеры, run-name и concurrency живут там. + workflow_call: + +# Concurrency стоит на job, а не на workflow (#499). На уровне workflow в +# группу issue попадал КАЖДЫЙ прогон — и от `polish`, и от `P2`, и от метки, +# которую переставил сам конвейер. GitHub держит в группе один идущий и один +# ожидающий прогон, и новый ожидающий вытесняет старого: ожидавший S7-code-review +# отменялся первой же посторонней меткой. Теперь посторонняя метка не запускает +# ни одной job (`if` на guard) и в группу не входит. + +# #556: права выдаются по job, а не одной строкой на весь workflow. Общий блок +# давал `issues: write` и OIDC даже той стадии, которая ничего не пишет, — +# модели. Ниже минимум на каждую: писать в issue умеют только детерминированные +# стадии, OIDC нужен исключительно `claude-code-action`. +permissions: + contents: read + +jobs: + guard: + name: "Страж: ребейз на dev и предпосылки ревью" + # Читает и переставляет метки, комментирует отказ. + permissions: + contents: read + issues: write + # Только статусные метки этапов ревью запускают конвейер (#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 }} + spent: ${{ steps.decide.outputs.spent }} + limit: ${{ steps.decide.outputs.limit }} + steps: + # Мелкий checkout: guard остаётся лёгким, но ему нужен + # scripts/review-doc-guard.mjs — счёт раундов вынесен туда, потому что + # inline-shell не покрывается тестами (#454). Node на раннере + # предустановлен, setup-node не нужен. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 1 + ref: dev + persist-credentials: false + - id: decide + env: + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + REPO: ${{ github.repository }} + LABEL: ${{ github.event.label.name }} + 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 + S4-spec-review) stage="spec"; marker="SPEC-REVIEW" ;; + 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 + if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi + + # Считаются ДВЕ РАЗНЫЕ величины, и это не педантизм (#227). + # + # `attempt` — сколько раз ревью уже отработало на этом этапе. Он нужен + # только для имени документа и метки: два захода с одинаковым номером + # означают, что второй документ перезапишет первый и артефакт ревью + # исчезнет. + # + # `spent` — сколько циклов израсходовано из бюджета §4. Цикл — это + # «отправка на ревью → вердикт с блокирующими находками → возврат + # автору», поэтому бюджет тратят ТОЛЬКО жёлтые и красные вердикты. + # Зелёный ничего на правки не вернул и цикла не образует. + # + # Раньше обе роли исполнял один счётчик всех вердиктов, и конвейер + # наказывал за то, что предписывал сам: при неудавшемся слиянии он + # велит вернуть S7-code-review после ребейза, и этот заход добивал + # бюджет. На #225 (лёгкий трек, лимит 2) последовательность + # жёлтый → зелёный → ребейз дала review-4 на задаче с зелёным ревью и + # зелёным CI: работа встала, хотя после вердикта не было ни одной + # правки продуктового кода. + # + # Вердикты считаются ТОЛЬКО своего этапа: иначе вердикт по ТЗ съедал + # цикл из бюджета код-ревью (#89 получило r2/4). Этап опознаётся по + # имени документа — раньше по подстроке маркера в теле комментария, + # теперь по имени документа ЭТОЙ задачи, `-`: голая + # подстрока протекала на прозе. #454 поймала это на себе — разбор + # чужих задач в комментарии содержал `CODE-REVIEW`, и первый же + # код-ревью получил заход r3. + # + # Счёт по комментариям остаётся ровно тем же, но он БОЛЬШЕ НЕ + # ЕДИНСТВЕННЫЙ (#454). Маркер этапа попадает в тело комментария, + # только если ревьюер сам назвал имя файла, — то есть прежний счёт + # зависел от формулировки. На #449 первый спек-вердикт файла не + # назвал, заход r2 получил номер r1, и документ второго раунда лёг + # ПОВЕРХ документа первого. Оценка «недосчёт обратим» была неверна + # ровно здесь: номер захода входит в имя файла, и повтор номера — не + # лишний заход, а потеря артефакта. + # + # Поэтому рядом встаёт второй источник — опубликованные документы: + # их имена несёт сам конвейер, и подделать их прозой нельзя. Берётся + # МАКСИМУМ двух источников: недосчёт возможен только при отказе + # обоих, перерасчёт невозможен по построению. + attempt=1; spent=0; spent_list="" + if [ -n "$stage" ]; then + comments=$(mktemp) + gh issue view "$NUM" --repo "$REPO" --json comments > "$comments" + + # Ветка задачи — та же, что выберет шаг ревью: свежая по коммиту. + # Её нет у задач, размеченных до появления конвейера; тогда счёт по + # файлам даёт ноль и работает страховка по комментариям. + branch=""; newest="" + for ref in $(gh api "repos/$REPO/git/matching-refs/heads/issue/$NUM-" \ + --jq '.[].ref' 2>/dev/null | sed 's|^refs/heads/||'); do + date=$(gh api "repos/$REPO/commits/$ref" --jq '.commit.committer.date' 2>/dev/null || true) + if [ -n "$date" ] && { [ -z "$newest" ] || [ "$date" \> "$newest" ]; }; then + newest="$date"; branch="$ref" + fi + done + target="${branch:-dev}" + names=$(mktemp); docs=$(mktemp -d) + # #621: каталог перечисляется Git Trees API, а не `contents`. + # У `contents` потолок 1 000 записей, после которого ответ молча + # обрезается; docs/reviews подошёл к нему (986 файлов на 23.09, + # +≈60 в неделю), и счёт по файлам начал бы занижаться — то есть + # повторять номер захода и класть документ поверх предыдущего. + # Дерево спускается по одному уровню: commit → root → docs → + # reviews; лимит дерева — 100 000 записей, флаг `truncated` здесь + # трактуется как отказ, а не как частичный список. + tree_names() { + local sha entry + sha=$(gh api "repos/$REPO/commits/$1" --jq '.commit.tree.sha') || return 1 + for entry in docs reviews; do + sha=$(gh api "repos/$REPO/git/trees/$sha" \ + --jq ".tree[] | select(.type == \"tree\" and .path == \"$entry\") | .sha") || return 1 + [ -n "$sha" ] || return 1 + done + gh api "repos/$REPO/git/trees/$sha" \ + --jq 'if .truncated then error("truncated") else .tree[] | select(.type == "blob") | .path end' + } + if ! tree_names "$target" > "$names" 2>/dev/null; then + : > "$names" + echo "::warning::дерево docs/reviews на $target не получено — счёт по файлам отключён" + fi + # Тела нужны только своим документам этапа: их единицы. + for name in $(grep -E "^${marker}-${NUM}-r[0-9]+\\.md$" "$names" || true); do + gh api "repos/$REPO/contents/docs/reviews/$name?ref=$target" \ + -H 'Accept: application/vnd.github.raw' > "$docs/$name" 2>/dev/null || rm -f "$docs/$name" + done + list=$(mktemp) + counters=$(node scripts/review-doc-guard.mjs --counters \ + --marker="$marker" --num="$NUM" --names="$names" --docs="$docs" \ + --comments="$comments" --spent-list="$list") + spent_list=$(cat "$list") + # Пустой ответ означает, что скрипт не отработал. Тогда остаются + # значения по умолчанию (заход 1, циклов 0): guard обязан + # продолжить работу, а не встать. + new_attempt=$(printf '%s\n' "$counters" | sed -n 's/^attempt=//p') + new_spent=$(printf '%s\n' "$counters" | sed -n 's/^spent=//p') + new_blocking=$(printf '%s\n' "$counters" | sed -n 's/^blocking=//p') + case "$new_attempt" in ''|*[!0-9]*) echo "::warning::счётчик раундов не дал числа — работают значения по умолчанию" ;; *) attempt="$new_attempt" ;; esac + case "$new_spent" in ''|*[!0-9]*) : ;; *) spent="$new_spent" ;; esac + # Перечень учтённого обязан сходиться с числом: если цикл виден + # только документом, ссылка на комментарий его не объяснит. + if [ -n "$new_blocking" ]; then + spent_list="$spent_list + - документы: $new_blocking" + fi + echo "ветка материала: ${branch:-нет, читался dev}" + fi + + # Отказ обязан быть виден в issue, а не только в логе прогона. + # Ревьюшная метка обещает работу; если конвейер её не начал и промолчал, + # задача стоит в этом статусе бесконечно и никто об этом не узнаёт. + # Так и вышло на #123: чужой issue довели до S4-spec-review, guard + # отказался за 9 секунд, и в issue не было ни слова. + # + # Пишем только когда пытались запустить ревью, то есть stage опознан. + # Иначе комментарий уходил бы на каждую смену любой метки. + refuse() { + echo "$1" + gh issue comment "$NUM" --repo "${{ github.repository }}" --body \ + "Конвейер ревью не запущен: $2 + + Метка \`$LABEL\` обещает работу, которая не начнётся, поэтому статус лучше вернуть в предыдущий — иначе задача простоит здесь бесконечно. [Прогон](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})." + stage="" + } + + # Автор issue здесь не проверяется (решение владельца 2026-08-13). + # Проверка стоит на входе в процесс, а не на каждом шаге: как только + # задача получила статусную метку, она в работе, и кто её завёл — не + # имеет значения. Само присвоение метки и есть явное подтверждение + # владельца, причём проверенное платформой: метки может ставить только + # тот, у кого есть право записи в репозиторий. Прежняя проверка здесь + # дублировала эту гарантию и заставляла переоформлять чужие отчёты + # своими issue — чистая работа впустую, как на #123. + if [ -z "$stage" ]; then + : + elif [ "$BLOCKED" = "true" ]; then + refuse "стоит blocked — конвейер не запускается" \ + "на issue стоит \`blocked\` — задача ждёт внешнего решения. Снять метку, когда решение принято." + elif [ "$EXHAUSTED" = "true" ]; then + # Метку снимает владелец, а не конвейер: автоматика, отменяющая + # остановку работы, дороже ручного снятия. Но пересчёт печатается — + # метка могла остаться от прежнего правила, когда бюджет тратил и + # зелёный вердикт (#227). + stale="" + if [ "$spent" -lt "$limit" ]; then + stale=" Пересчёт по действующему правилу: блокирующих циклов $spent из $limit — метка могла остаться от прежнего правила, когда бюджет тратил любой вердикт. Снять её может владелец." + fi + refuse "стоит review-4 — решение за владельцем" \ + "на issue стоит \`review-4\`: лимит циклов ревью исчерпан, дальше решает владелец — разделить задачу, отклонить или арбитраж (PROCESS.md §4).$stale" + elif [ "$spent" -ge "$limit" ]; then + echo "блокирующих циклов этапа $stage: $spent из $limit — лимит исчерпан" + gh issue edit "$NUM" --repo "${{ github.repository }}" --add-label review-4 + # Перечень учтённого обязателен: иначе владельцу приходится читать + # всю ленту, чтобы понять, из чего сложился счёт. + gh issue comment "$NUM" --repo "${{ github.repository }}" --body \ + "Лимит циклов ревью исчерпан: блокирующих циклов $spent из $limit на этапе \`$stage\` (заход $attempt). Следующего захода нет: решение владельца — разделить задачу, отклонить или арбитраж (PROCESS.md §4). + + Учтены вердикты с блокирующими находками — зелёные бюджет не тратят: + $spent_list" + stage="" + else + echo "этап $stage, заход $attempt, блокирующих циклов $spent из $limit" + fi + echo "stage=$stage" >> "$GITHUB_OUTPUT" + echo "cycle=$attempt" >> "$GITHUB_OUTPUT" + echo "spent=$spent" >> "$GITHUB_OUTPUT" + echo "limit=$limit" >> "$GITHUB_OUTPUT" + + prepare: + name: "Ревью: материал и deterministic gates" + # Ребейзит ветку задачи и комментирует возврат автору. + permissions: + contents: read + issues: write + needs: guard + if: needs.guard.outputs.stage != '' + runs-on: ubuntu-latest + concurrency: + group: process-issue-${{ github.event.issue.number }} + cancel-in-progress: false + # Ожидание Validate не отнимает бюджет у модели (#551). Сам gate может + # ждать 45 минут; подготовке оставлен отдельный запас на checkout/rebase. + timeout-minutes: 55 + outputs: + proceed: ${{ steps.gate.outputs.proceed }} + branch: ${{ steps.branch.outputs.name }} + rebase_note: ${{ steps.rebase.outputs.note }} + material_sha: ${{ steps.material.outputs.sha }} + material_tree: ${{ steps.material.outputs.tree }} + material_specs: ${{ steps.material.outputs.specs }} + material_issue_body: ${{ steps.material.outputs.issue_body }} + reuse: ${{ steps.reuse.outputs.reuse }} + reuse_doc: ${{ steps.reuse.outputs.doc }} + reuse_round: ${{ steps.reuse.outputs.round }} + reuse_tree: ${{ steps.reuse.outputs.tree }} + validate_result: ${{ steps.gate.outputs.result }} + validate_url: ${{ steps.gate.outputs.url }} + validated_note: ${{ steps.validated.outputs.note }} + spec_body_changed: ${{ steps.spec_body.outputs.changed }} + spec_body_doc: ${{ steps.spec_body.outputs.doc }} + spec_body_recorded: ${{ steps.spec_body.outputs.recorded }} + duration_seconds: ${{ steps.duration.outputs.seconds }} + steps: + - name: Начать измерение стадии + id: clock + run: echo "started=$(date +%s)" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + ref: dev + # Иначе в конфиге git остаётся креденшел GITHUB_TOKEN, и push с + # мёртвым PAT молча уходит от github-actions[bot] — 403 при + # contents: read. Отказ обязан быть громким и правильным. + persist-credentials: false + + # Живость PAT проверяется ДО ревью. На #150 истёкший токен обнаружился + # только на публикации документа — после сорока минут работы ревьюера. + - name: Секрет HP_PROCESS_TOKEN жив + env: + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + run: | + if [ -z "$GH_TOKEN" ]; then + echo "::error::HP_PROCESS_TOKEN пуст — секрет удалён или недоступен" + exit 1 + fi + if ! login=$(gh api user -q .login 2>/dev/null); then + echo "::error::HP_PROCESS_TOKEN не аутентифицируется — истёк или отозван. Обновить: Settings -> Secrets and variables -> Actions -> HP_PROCESS_TOKEN" + exit 1 + fi + echo "токен жив, действует от: $login" + + # Окружение готовит workflow, а не модель своими ходами. Раньше промпт + # велел ревьюеру самому выполнить `npm ci`: минуты уходили на установку без + # кэша, платились из бюджета 45 минут и из лимитов подписки, а ходы модели + # тратились на работу инфраструктуры. В validate.yml кэш стоит на всех + # тяжёлых job, здесь его не было. + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + cache: npm + + # Материал ревью живёт в ветке задачи: ТЗ в docs/specs/ и код коммитятся + # в issue/-slug. Если ветка запушена — переключаемся на неё, иначе + # ревьюер прочтёт dev и не найдёт того, что должен оценивать. + - name: Перейти на ветку задачи + id: branch + env: + NUM: ${{ github.event.issue.number }} + run: | + # Свежая по последнему коммиту, а не первая по алфавиту: на #150 рядом + # жили ветка ТЗ и ветка реализации, и head -1 выбрал устаревшую. + git fetch -q origin "+refs/heads/issue/${NUM}-*:refs/remotes/origin/issue/${NUM}-*" || true + branches=$(git for-each-ref --sort=-committerdate \ + --format='%(refname:lstrip=3)' "refs/remotes/origin/issue/${NUM}-*") + branch=$(printf '%s\n' "$branches" | head -1) + if [ "$(printf '%s\n' "$branches" | grep -c .)" -gt 1 ]; then + echo "::warning::веток issue/${NUM}-* несколько ($(echo $branches | tr '\n' ' ')) — выбрана свежая по коммиту: $branch. Устаревшую следует удалить." + fi + if [ -n "$branch" ]; then + git checkout -q "origin/$branch" + echo "материал ревью: ветка $branch, $(git rev-parse --short HEAD)" + echo "name=$branch" >> "$GITHUB_OUTPUT" + # Якоря материала (sha, tree, specs) снимает шаг `material` — ПОСЛЕ + # ребейза (#515): снятые здесь, они после force-push приведённой + # ветки указывали на осиротевший коммит, и ни reuse (#499), ни + # страховка #414 не находили дерева в свежем клоне. + else + echo "::warning::ветка issue/${NUM}-* не найдена на origin — ревью пойдёт по dev" + echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY" + fi + + # Ревьюер обязан смотреть тот же код, который уедет в dev (#257). Раньше + # ревью шло по ветке как есть, а слияние делало ребейз — проверенный SHA и + # слитый SHA были разными коммитами. Пока расхождение с dev текстовое, + # ребейз упирается в конфликт и это видно; смысловое расхождение git + # склеивает молча, и в dev уезжает комбинация, которую никто не читал. + # Именно так пришёл регресс #234. + # + # Заодно снимается плата за конфликт: он обнаруживался ПОСЛЕ сорока минут + # ревью и потраченных лимитов подписки, хотя виден за пять секунд до них. + # + # Этап spec не затрагивается: ветку ТЗ в dev никто не сливает, и трогать + # чужую ветку без нужды — лишний риск. + - name: Привести ветку к dev + id: rebase + if: needs.guard.outputs.stage == 'code' && steps.branch.outputs.name != '' + env: + TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + # #539: тем же токеном спрашивается REST — через него идёт и диспатч. + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + BRANCH: ${{ steps.branch.outputs.name }} + NUM: ${{ github.event.issue.number }} + # rebase, в отличие от commit, не принимает -c user.*: он запускает + # свои процессы и требует личность в окружении, иначе падает с + # «unable to auto-detect email address». + GIT_AUTHOR_NAME: claude[bot] + GIT_AUTHOR_EMAIL: 209825114+claude[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: claude[bot] + GIT_COMMITTER_EMAIL: 209825114+claude[bot]@users.noreply.github.com + run: | + git fetch -q origin dev + if git merge-base --is-ancestor origin/dev HEAD; then + echo "ветка содержит весь dev — ребейз не нужен" + exit 0 + fi + behind=$(git rev-list --count "HEAD..origin/dev") + before=$(git rev-parse "origin/$BRANCH") + echo "dev впереди на $behind коммит(ов) — привожу ветку" + # #643: doc-коммит ветки конфликтует с документами других задач в dev + # только в генерируемом docs/reviews/INDEX.md — всегда, на каждом + # сдвиге dev. Помощник ребейза пересобирает индекс по каталогу, если + # ВСЕ конфликты остановки — индекс, и отказывает (с abort) на любом + # другом. Список конфликтов он снимает ДО abort (#364) и печатает в + # stdout по строке. Помощник берётся из dev, а не из ветки: ветка, + # отставшая от dev, его ещё не несёт. + tools="$RUNNER_TEMP/rebase-tools" + rm -rf "$tools" && mkdir -p "$tools" + git archive origin/dev scripts | tar -x -C "$tools" + code=0 + files=$(node "$tools/scripts/rebase-generated.mjs" --onto=origin/dev) || code=$? + # Код 3 — отказ с перечнем (ребейз отменён); любой другой ненулевой — + # сбой самого помощника, конфликтом ветки он не выдаётся. + if [ "$code" -ne 0 ] && [ "$code" -ne 3 ]; then + git rebase --abort 2>/dev/null || true + echo "::error::помощник ребейза упал (код $code) — это сбой конвейера, а не конфликт ветки" + exit 1 + fi + if [ "$code" -eq 3 ]; then + files=$(printf '%s\n' "$files" | sed '/^$/d' | sort -u | paste -sd'\n' -) + { + echo 'conflict=true' + echo 'conflicts<> "$GITHUB_OUTPUT" + echo "::warning::ветка $BRANCH не ребейзится на dev без конфликта — ревью не запускается" + printf 'конфликтуют:\n%s\n' "${files:-(git не назвал файлы)}" + exit 0 + fi + # #635 r2: ребейз мог принести в docs/reviews документы других задач, + # а закоммиченный INDEX.md — снимок каталога — их не знает. Свежесть + # держит тест «индекс свеж» в Validate, поэтому индекс пересобирается + # здесь же, коммитом конвейера (класс C), до фиксации материала. + node scripts/reviews-index.mjs --dir=docs/reviews --commit-if-stale --issue="$NUM" + # --force-with-lease с явным ожидаемым значением обязателен: между + # fetch и push автор мог запушить коммит, и слепой --force потерял бы + # его молча. Расхождение lease — падение прогона, а не предупреждение: + # ревью пошло бы по коду, которого на ветке уже нет. + if ! git push -q --force-with-lease="refs/heads/$BRANCH:$before" \ + "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \ + "HEAD:refs/heads/$BRANCH"; then + echo "::error::ветка $BRANCH изменилась во время ребейза — прогон прерван, чтобы не потерять коммит автора" + exit 1 + fi + # #539: ссылка на стороне GitHub доезжает не мгновенно, а гейт ниже + # просит `workflow_dispatch` ПО ИМЕНИ ВЕТКИ — SHA туда передать + # нельзя. 12.09 на #536 диспатч, отправленный через три секунды после + # этого push, встал на ДОпушевый SHA: гейт не нашёл прогона на + # материале и вернул задачу автору, которому чинить было нечего. + # Поэтому шаг не заканчивается, пока REST не отдаст новую вершину — + # именно REST, потому что через него же идёт и сам диспатч. + after=$(git rev-parse HEAD) + settled=false + for _ in $(seq 1 30); do + seen=$(gh api "repos/${{ github.repository }}/git/ref/heads/$BRANCH" \ + --jq .object.sha 2>/dev/null || true) + if [ "$seen" = "$after" ]; then settled=true; break; fi + sleep 2 + done + if [ "$settled" != "true" ]; then + echo "::error::ссылка $BRANCH за минуту не стала указывать на $after — диспатч встал бы на устаревший SHA" + exit 1 + fi + # Локальная ссылка обновляется тоже: шаг слияния берёт origin/$BRANCH, + # и без этого он ребейзил бы заново уже приведённое. + git fetch -q origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH" + short_before=$(git rev-parse --short "$before") + short_after=$(git rev-parse --short HEAD) + echo "note=Ветка приведена к dev конвейером до ревью: поверх легло $behind коммит(ов) dev, $short_before -> $short_after. После ребейза это другой код (§7.2) — разбор полный, а не по дельте." >> "$GITHUB_OUTPUT" + echo "ветка $BRANCH приведена к dev: $short_before -> $short_after" + + # Материал ревью — конкретный SHA (#312). Вердикт применим только к + # нему: если во время ревью в ветку прилетит коммит, шаг слияния обязан + # это заметить и отказаться, а не молча увезти в dev непроверенный код. + - name: Зафиксировать SHA материала ревью + id: material + if: steps.rebase.outputs.conflict != 'true' + env: + NUM: ${{ github.event.issue.number }} + REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + run: | + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + # Якоря материала, устойчивые к ребейзу (#413, #414). SHA коммита + # ребейз меняет — содержимое нет: git адресует деревья и блобы их + # хешем. Снимаются здесь, ПОСЛЕ приведения к dev (#515): рабочая + # копия равна тому, что ревьюер прочтёт, и коммит с этим деревом + # уже запушен в ветку — следующий прогон найдёт его в свежем клоне. + # В шаге публикации дерево уже сброшено на целевую ветку, и + # спрашивать его поздно. + echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT" + # ТЗ задачи: блоб переживает и ребейз, и удаление ветки, пока текст + # где-нибудь достижим. Файлов может не быть (инфраструктурная + # задача) или быть несколько (разбитое ТЗ) — тогда список пуст либо + # длиннее одного. + specs=$(git ls-files -s -- "docs/specs/${NUM}-*.md" \ + | awk '{print $2" "$4}' | tr '\n' ';') + echo "specs=$specs" >> "$GITHUB_OUTPUT" + # Тело issue — материал ревью ТЗ (#517): с переходом на ТЗ в теле это + # единственный якорь, доказывающий «вердикт вынесен на этом тексте». + # Читается здесь, а не из github.event.issue.body: между событием + # метки и вызовом модели проходят минуты (ребейз, гейт #510, ожидание + # Validate), и снимок события описывал бы не тот текст. + body=$(mktemp) + if gh issue view "$NUM" --repo "$REPO" --json body --jq .body > "$body"; then + digest=$(node -e ' + import("./scripts/review-doc-guard.mjs").then(async (m) => { + const { readFileSync } = await import("node:fs"); + process.stdout.write(m.issueBodyDigest(readFileSync(process.argv[1], "utf8"))); + }); + ' "$body") + echo "issue_body=$digest" >> "$GITHUB_OUTPUT" + echo "тело issue: ${digest:0:12}" + else + echo "::warning::тело issue $NUM не прочитано — якорь ТЗ в документ не попадёт" + fi + echo "материал ревью: $(git rev-parse --short HEAD), дерево $(git rev-parse --short 'HEAD^{tree}')" + + # Повторное применение зелёного вердикта без вызова модели (#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 }} + # Правка ТЗ между раундами обязана отменять повторное применение + # зелёного вердикта: иначе вызов модели пропускается и находку + # «ТЗ менялось» некому напечатать (#517 AC6). + ISSUE_BODY: ${{ steps.material.outputs.issue_body }} + run: | + out=$(node scripts/review-doc-guard.mjs --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD \ + --issue-body="${ISSUE_BODY}") + 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 — + # тоже смена метки, и автор не ждёт впустую. + - name: Конфликт с dev — вернуть автору без ревью + if: steps.rebase.outputs.conflict == 'true' + env: + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + NUM: ${{ github.event.issue.number }} + BRANCH: ${{ steps.branch.outputs.name }} + CONFLICTS: ${{ steps.rebase.outputs.conflicts }} + run: | + cat > /tmp/stale.md < S6-in-progress (ревью не запускалось)" + + # Мутанты по диффу бегут только по запросу (#510): до ревью конвейер + # запускает Validate с мутантами на материале и ждёт его. Красный или + # пропавший прогон возвращает задачу автору без ревью — цикл не + # тратится на код, который CI уже отверг (08.09: #437 дважды ушёл в S6 + # после запущенного 15-минутного ревью). Этап spec кода не несёт и + # гейт не проходит; повторное применение вердикта (#499) — тоже: там + # слияние само дожидается Validate на кандидате. + - name: Validate с мутантами на материале + id: gate + if: steps.rebase.outputs.conflict != 'true' + env: + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + STAGE: ${{ needs.guard.outputs.stage }} + REUSE: ${{ steps.reuse.outputs.reuse }} + BRANCH: ${{ steps.branch.outputs.name }} + SHA: ${{ steps.material.outputs.sha }} + run: | + if [ "$STAGE" != "code" ] || [ "$REUSE" = "true" ] || [ -z "$BRANCH" ]; then + echo "гейт не применяется: этап $STAGE, reuse=${REUSE:-false}, ветка ${BRANCH:-dev}" + { echo 'proceed=true'; echo 'result=skipped'; } >> "$GITHUB_OUTPUT" + exit 0 + fi + # #636: раннер не спит, пока идёт Validate (28 минут на раунд при + # 10–12 минутах работы модели). Гейт диспатчит прогон, убеждается, что + # тот встал на материал, и выходит с кодом 2 — «идёт». Раунд продолжит + # событие завершения Validate (process-resume.yml переставит метку + # S7), страховка — process-reconcile. Зелёный или красный завершённый + # прогон гейт и без ожидания возвращает сразу. + set +e + node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA" --no-wait + code=$? + set -e + case "$code" in + 0) echo 'proceed=true' >> "$GITHUB_OUTPUT" ;; + 2) echo 'proceed=pending' >> "$GITHUB_OUTPUT" ;; + *) echo 'proceed=false' >> "$GITHUB_OUTPUT" ;; + esac + + # #636: прогон на материале идёт — записать маркер ожидания и освободить + # раннер. Метка S7 остаётся; событие `workflow_run` по завершении Validate + # переставит её, и новый прогон конвейера найдёт завершённый dispatch + # сразу. Маркер читают process-resume.mjs и process-reconcile.mjs: без + # него ни один из них не имеет права будить раунд — иначе «успешный + # прогон без вердикта» неотличим от потерянного запроса. + - name: Validate идёт — раунд продолжит событие + id: pending + if: steps.rebase.outputs.conflict != 'true' && steps.gate.outputs.proceed == 'pending' + env: + NUM: ${{ github.event.issue.number }} + STAGE: ${{ needs.guard.outputs.stage }} + BRANCH: ${{ steps.branch.outputs.name }} + SHA: ${{ steps.material.outputs.sha }} + VALIDATE_RUN_ID: ${{ steps.gate.outputs.run_id }} + VALIDATE_URL: ${{ steps.gate.outputs.url }} + run: | + dir="$RUNNER_TEMP/review-pending" + mkdir -p "$dir" + jq -n -S \ + --arg run_id "$GITHUB_RUN_ID" --arg run_attempt "$GITHUB_RUN_ATTEMPT" \ + --arg issue "$NUM" --arg stage "$STAGE" --arg branch "$BRANCH" \ + --arg material_sha "$SHA" --arg validate_run_id "$VALIDATE_RUN_ID" \ + --arg validate_url "$VALIDATE_URL" \ + '{schema:1,run_id:$run_id,run_attempt:$run_attempt,issue:$issue,stage:$stage,branch:$branch,material_sha:$material_sha,validate_run_id:$validate_run_id,validate_url:$validate_url}' \ + > "$dir/pending.json" + (cd "$dir" && sha256sum pending.json > manifest.sha256) + echo "artifact=review-pending-${NUM}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" + short=$(git rev-parse --short "$SHA") + echo "Validate с мутантами на \`$short\` идёт — раннер освобождён, раунд продолжится по завершении прогона${VALIDATE_URL:+ ($VALIDATE_URL)}." >> "$GITHUB_STEP_SUMMARY" + + - name: Сохранить маркер ожидания + if: steps.gate.outputs.proceed == 'pending' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ${{ steps.pending.outputs.artifact }} + path: ${{ runner.temp }}/review-pending + if-no-files-found: error + retention-days: 1 + + - name: Validate красный — вернуть автору без ревью + if: steps.rebase.outputs.conflict != 'true' && steps.gate.outputs.proceed == 'false' + env: + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + NUM: ${{ github.event.issue.number }} + BRANCH: ${{ steps.branch.outputs.name }} + SHA: ${{ steps.material.outputs.sha }} + RESULT: ${{ steps.gate.outputs.result }} + NOTE: ${{ steps.gate.outputs.note }} + URL: ${{ steps.gate.outputs.url }} + run: | + short=$(git rev-parse --short "$SHA") + cat > /tmp/gate.md < S6-in-progress (Validate с мутантами: $RESULT)" + + # Ревьюер перегонял tsc, юниты и сборку заново в каждом раунде, хотя + # Validate на том же SHA уже зелёный (#343). Это не тщательность: бюджет + # ревью тратится на повторение CI вместо чтения кода. + # + # Доказательство здесь такое же строгое, как у reuse-маркеров (#208): не + # «недавно было зелено», а «completed success ровно на этом SHA». После + # ребейза SHA другой, прогона для него нет — и ревьюер честно гоняет сам. + - name: Зелёные гейты на этом SHA + id: validated + if: steps.gate.outputs.proceed == 'true' + env: + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + run: | + sha=$(git rev-parse HEAD) + short=$(git rev-parse --short HEAD) + row=$(gh run list --repo "${{ github.repository }}" --workflow validate.yml \ + --commit "$sha" --limit 5 \ + --json status,conclusion,url \ + --jq '[.[] | select(.status=="completed" and .conclusion=="success")][0] // empty') + { + echo 'note<s+=d).on("end",()=>process.stdout.write(JSON.parse(s).url||""))') + echo "**Дешёвые гейты на этом SHA уже подтверждены** (#343). Validate на \`$short\` завершился success: $url" + echo "" + echo "Значит \`npx tsc --noEmit\`, \`npm test\` и \`npm run build\` со сверкой копий бандла перегонять не нужно — сошлись на этом прогоне, назвав его ссылкой. Бюджет раунда тратится на чтение кода." + echo "" + echo "Что Validate НЕ покрывает и остаётся за тобой: смоки, выбранные по диффу; golden, если diff трогает рендер; инварианты модели на конкретной конфигурации; и любой гейт, который требуют AC задачи." + else + echo "**Зелёного Validate на этом SHA (\`$short\`) нет** — прогон не найден, не завершён либо не success. Дешёвые гейты прогоняешь сам и называешь результат." + fi + echo 'EOF_NOTE' + } >> "$GITHUB_OUTPUT" + if [ -n "$row" ]; then echo "Validate на $short: зелёный"; else echo "Validate на $short: зелёного нет"; fi + + # Это deterministic evidence для промпта, поэтому вычисляется до запуска + # модели и передаётся вместе с неизменяемым контрактом материала (#551). + - name: "ТЗ менялось после зелёного ревью ТЗ (#517)" + id: spec_body + if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' && needs.guard.outputs.stage == 'code' + env: + NUM: ${{ github.event.issue.number }} + DIGEST: ${{ steps.material.outputs.issue_body }} + run: | + if [ -z "$DIGEST" ]; then + echo "хеша тела нет — сравнивать не с чем" + exit 0 + fi + out=$(node -e ' + import("./scripts/review-doc-guard.mjs").then(async (m) => { + const { execFileSync } = await import("node:child_process"); + const [num, digest] = process.argv.slice(1); + const git = (args) => { try { return execFileSync("git", args, { encoding: "utf8" }); } catch { return ""; } }; + const names = git(["ls-tree", "--name-only", "HEAD:docs/reviews"]).split("\n") + .filter((name) => new RegExp(`^SPEC-REVIEW-${num}-r\\d+\\.md$`).test(name)); + const docs = names.map((name) => ({ name, text: git(["show", `HEAD:docs/reviews/${name}`]) })); + const changed = m.issueBodyChanged(docs, digest); + if (changed) process.stdout.write(`changed=true\ndoc=${changed.doc}\nrecorded=${changed.recorded}\n`); + else process.stdout.write("changed=false\n"); + }); + ' "$NUM" "$DIGEST") + printf '%s\n' "$out" + printf '%s\n' "$out" >> "$GITHUB_OUTPUT" + + - name: Собрать контракт материала между стадиями + id: prepared + if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' + env: + NUM: ${{ github.event.issue.number }} + STAGE: ${{ needs.guard.outputs.stage }} + CYCLE: ${{ needs.guard.outputs.cycle }} + BRANCH: ${{ steps.branch.outputs.name }} + MATERIAL_SHA: ${{ steps.material.outputs.sha }} + MATERIAL_TREE: ${{ steps.material.outputs.tree }} + MATERIAL_SPECS: ${{ steps.material.outputs.specs }} + MATERIAL_ISSUE_BODY: ${{ steps.material.outputs.issue_body }} + VALIDATE_RESULT: ${{ steps.gate.outputs.result }} + VALIDATE_URL: ${{ steps.gate.outputs.url }} + REBASE_NOTE: ${{ steps.rebase.outputs.note }} + VALIDATED_NOTE: ${{ steps.validated.outputs.note }} + SPEC_BODY_CHANGED: ${{ steps.spec_body.outputs.changed }} + SPEC_BODY_DOC: ${{ steps.spec_body.outputs.doc }} + SPEC_BODY_RECORDED: ${{ steps.spec_body.outputs.recorded }} + run: | + dir="$RUNNER_TEMP/review-prepared" + mkdir -p "$dir" + jq -n -S \ + --arg run_id "$GITHUB_RUN_ID" --arg run_attempt "$GITHUB_RUN_ATTEMPT" \ + --arg issue "$NUM" --arg stage "$STAGE" --arg cycle "$CYCLE" \ + --arg branch "$BRANCH" --arg material_sha "$MATERIAL_SHA" \ + --arg material_tree "$MATERIAL_TREE" --arg material_specs "$MATERIAL_SPECS" \ + --arg material_issue_body "$MATERIAL_ISSUE_BODY" \ + --arg validate_result "$VALIDATE_RESULT" --arg validate_url "$VALIDATE_URL" \ + --arg rebase_note "$REBASE_NOTE" --arg validated_note "$VALIDATED_NOTE" \ + --arg spec_body_changed "$SPEC_BODY_CHANGED" --arg spec_body_doc "$SPEC_BODY_DOC" \ + --arg spec_body_recorded "$SPEC_BODY_RECORDED" \ + '{schema:1,run_id:$run_id,run_attempt:$run_attempt,issue:$issue,stage:$stage,cycle:$cycle,branch:$branch,material_sha:$material_sha,material_tree:$material_tree,material_specs:$material_specs,material_issue_body:$material_issue_body,validate_result:$validate_result,validate_url:$validate_url,rebase_note:$rebase_note,validated_note:$validated_note,spec_body_changed:$spec_body_changed,spec_body_doc:$spec_body_doc,spec_body_recorded:$spec_body_recorded}' \ + > "$dir/prepared.json" + (cd "$dir" && sha256sum prepared.json > manifest.sha256) + echo "artifact=review-prepared-${NUM}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" + + - name: Передать подтверждённый материал модели + if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ${{ steps.prepared.outputs.artifact }} + path: ${{ runner.temp }}/review-prepared + if-no-files-found: error + retention-days: 1 + + - name: Зафиксировать длительность подготовки + id: duration + if: always() + env: + STARTED: ${{ steps.clock.outputs.started }} + run: | + seconds=$(( $(date +%s) - STARTED )) + echo "seconds=$seconds" >> "$GITHUB_OUTPUT" + echo "- deterministic prerequisites: **${seconds}s**" >> "$GITHUB_STEP_SUMMARY" + + model_review: + name: "Ревью: работа модели" + # Единственная недоверенная стадия: модель читает материал и кладёт + # запечатанный artifact. Писать в issue и в репозиторий ей незачем — это + # делает `integrate`, проверив происхождение: в репозиторий модель не пишет + # вообще, а в issue — только своим комментарием и отдельным issue по §12. + # + # Права здесь реальны только вместе с `github_token` у шага Review. Без него + # claude-code-action меняет OIDC на собственный App-токен с дефолтом + # `contents/issues/pull_requests: write` (`src/github/token.ts`), и блок ниже + # не ограничивает ничего: токен `ghs_…` от claude[bot] лежит прямо в + # окружении Bash-инструмента модели — это поймало ревью r1 по #556 в + # собственной же сессии. С переданным `secrets.GITHUB_TOKEN` обмена не + # происходит, `id-token` больше не нужен, и этот список становится потолком. + # + # `issues: write` остаётся: процесс требует от ревьюера комментарий с + # вердиктом (§7.2) и отдельный issue на Medium вне скоупа (§12). Снять его + # можно только перенеся и то и другое в `integrate` — это отдельная правка + # конвейера, не эта задача. Что остаётся модели этим правом: комментарий, + # метки, правка тела issue. Чего не остаётся: запись в репозиторий, слияние + # (его решает запечатанный verdict.json в `integrate`), релиз. + permissions: + contents: read + issues: write + needs: [guard, prepare] + if: needs.prepare.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' + runs-on: ubuntu-latest + concurrency: + group: process-issue-${{ github.event.issue.number }} + cancel-in-progress: false + # Весь бюджет принадлежит модели и её локальным проверкам; ожидания Validate + # в этом job больше нет (#551). + timeout-minutes: 45 + outputs: + duration_seconds: ${{ steps.duration.outputs.seconds }} + steps: + - name: Начать измерение стадии + id: clock + run: echo "started=$(date +%s)" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + ref: ${{ needs.prepare.outputs.material_sha }} + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 22 + cache: npm + + - name: Получить контракт подготовленного материала + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: review-prepared-${{ github.event.issue.number }}-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/review-prepared + + - name: Проверить контракт и exact material + env: + NUM: ${{ github.event.issue.number }} + STAGE: ${{ needs.guard.outputs.stage }} + CYCLE: ${{ needs.guard.outputs.cycle }} + BRANCH: ${{ needs.prepare.outputs.branch }} + MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }} + MATERIAL_TREE: ${{ needs.prepare.outputs.material_tree }} + MATERIAL_SPECS: ${{ needs.prepare.outputs.material_specs }} + MATERIAL_ISSUE_BODY: ${{ needs.prepare.outputs.material_issue_body }} + VALIDATE_RESULT: ${{ needs.prepare.outputs.validate_result }} + VALIDATE_URL: ${{ needs.prepare.outputs.validate_url }} + REBASE_NOTE: ${{ needs.prepare.outputs.rebase_note }} + VALIDATED_NOTE: ${{ needs.prepare.outputs.validated_note }} + SPEC_BODY_CHANGED: ${{ needs.prepare.outputs.spec_body_changed }} + SPEC_BODY_DOC: ${{ needs.prepare.outputs.spec_body_doc }} + SPEC_BODY_RECORDED: ${{ needs.prepare.outputs.spec_body_recorded }} + run: | + dir="$RUNNER_TEMP/review-prepared" + (cd "$dir" && sha256sum -c manifest.sha256) + jq -e \ + --arg run_id "$GITHUB_RUN_ID" --arg run_attempt "$GITHUB_RUN_ATTEMPT" \ + --arg issue "$NUM" --arg stage "$STAGE" --arg cycle "$CYCLE" \ + --arg branch "$BRANCH" --arg sha "$MATERIAL_SHA" --arg tree "$MATERIAL_TREE" \ + --arg specs "$MATERIAL_SPECS" --arg body "$MATERIAL_ISSUE_BODY" \ + --arg validate "$VALIDATE_RESULT" --arg validate_url "$VALIDATE_URL" \ + --arg rebase_note "$REBASE_NOTE" --arg validated_note "$VALIDATED_NOTE" \ + --arg spec_changed "$SPEC_BODY_CHANGED" --arg spec_doc "$SPEC_BODY_DOC" \ + --arg spec_recorded "$SPEC_BODY_RECORDED" \ + '.schema == 1 and .run_id == $run_id and .run_attempt == $run_attempt and .issue == $issue and .stage == $stage and .cycle == $cycle and .branch == $branch and .material_sha == $sha and .material_tree == $tree and .material_specs == $specs and .material_issue_body == $body and .validate_result == $validate and .validate_url == $validate_url and .rebase_note == $rebase_note and .validated_note == $validated_note and .spec_body_changed == $spec_changed and .spec_body_doc == $spec_doc and .spec_body_recorded == $spec_recorded' \ + "$dir/prepared.json" + test "$(git rev-parse HEAD)" = "$MATERIAL_SHA" + test "$(git rev-parse 'HEAD^{tree}')" = "$MATERIAL_TREE" + + # Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог + # измениться именно в ней, и установка по копии из dev дала бы не то дерево. + - name: Установить зависимости + run: npm ci + + # Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте), + # но когда нужен — качать его заново дороже, чем держать в кэше. + - name: Кэш браузеров Playwright + id: pw + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + + - name: Установить Chromium + if: steps.pw.outputs.cache-hit != 'true' + # Без --with-deps: системные библиотеки Chromium предустановлены в + # образе ubuntu-latest, а apt при промахе кэша съедал минуты из бюджета + # ревью и подолгу перебирал недоступное azure-зеркало (#175). Если + # библиотека когда-нибудь пропадёт из образа, Chromium не запустится с + # внятной ошибкой — тогда флаг вернуть. + 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 + 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 + uses: anthropics/claude-code-action@9cdae7f0d995e3ba7c33f226087fdf82a59cd520 # v1 + env: + # Вне рабочей копии: восстановление дерева ревьюером не должно + # уничтожать его собственный артефакт (#220). + REVIEW_DOC: ${{ runner.temp }}/review-document.md + with: + # Подписка, а не отдельный счёт API: токен выпускается через + # `claude setup-token` (Pro/Max). Действуют лимиты подписки. + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + # Ambient job-scoped токен вместо App-обмена (#556). Со ним + # `permissions:` этой job — настоящий потолок прав модели: ни записи в + # репозиторий, ни постановки метки, ни комментария. Строку нельзя + # снять, не вернув модели право двигать процесс. + github_token: ${{ secrets.GITHUB_TOKEN }} + path_to_claude_code_executable: ${{ steps.claude_bin.outputs.path }} + prompt: | + Ты ревьюер проекта House Plan. Язык ответа — русский. + + Issue: #${{ github.event.issue.number }} + Репозиторий: ${{ github.repository }} + Этап: ${{ needs.guard.outputs.stage }} + spec — ревью ТЗ (PROCESS.md §2.4) + code — код-ревью (PROCESS.md §2.7) + Заход: r${{ needs.guard.outputs.cycle }} · блокирующих циклов израсходовано ${{ needs.guard.outputs.spent }} из ${{ needs.guard.outputs.limit }} + Бюджет §4 тратят только жёлтые и красные вердикты: зелёный + ничего не вернул на правки и цикла не образует (#227). + Номер захода нужен для имени документа — два документа с + одинаковым номером затёрли бы друг друга. + + ${{ needs.prepare.outputs.rebase_note }} + + ${{ needs.prepare.outputs.spec_body_changed == 'true' && format('ТЗ в теле issue менялось после зелёного ревью ТЗ ({0}, записанный хеш {1}). GitHub хранит правки тела без diff — дельту показать нельзя, поэтому AC сверяются с ТЕКУЩИМ текстом целиком, а не по дельте, и находка называется в вердикте (#517).', needs.prepare.outputs.spec_body_doc, needs.prepare.outputs.spec_body_recorded) || '' }} + + Правила ревью в этом промпте не повторяются (#634): их канон — + PROCESS.md, выжимка для ревьюера — docs/process/REVIEWER.md, где + каждый пункт ссылается на свой раздел канона. Ниже — только то, что + относится к этому прогону, и требования, которые нельзя пропустить. + + Прочитай в этом порядке, прежде чем судить: + 1. docs/SCOPE.md — зачем продукт существует и для кого. Он + ограничитель: «features are built, improved and accepted only + if they serve a job listed here». Первый вопрос к задаче — + какую строку Core user jobs она закрывает. + 2. docs/process/REVIEWER.md — обязанности ревьюера: позиция, + ревью ТЗ, код-ревью, объём гейтов, повторный раунд, находки и + вердикт. Раздел PROCESS.md по ссылке открывай, когда пункт + касается твоего решения; при расхождении прав PROCESS.md. Если + файла в материале нет — читай PROCESS.md §2.4, §2.7, §2.10, + §4, §7.2, §8, §12. Задача правит сам конвейер, гейты или + процесс — PROCESS.md целиком, §10 в первую очередь. + 3. AGENTS.md — классы изменений, трейлеры, гейты, формат вердикта. + 4. Тело issue #${{ github.event.issue.number }} и все комментарии. + 5. Если меняется видимое поведение — docs/USER-GUIDE.ru.md: + терминология интерфейса берётся оттуда, а не изобретается. + 6. Канонический документ затронутой подсистемы: docs/SUN.md, + LIGHT.md, CANVAS.md, WALL-THICKNESS.md, UX-MODES.md, + CONFIG-COMPATIBILITY.md, TOUCH-SUPPORT.md. + + **Если цикл не первый — объём разбора по дельте, а не заново** + (PROCESS.md §2.10, issue #214): найди вердикт и материал + предыдущего раунда (блок «Материал раунда» его документа), объяви + дельту `git diff <тот SHA>..HEAD` (для spec — дифф тела issue или + файла ТЗ), по каждой находке покажи, чем именно она закрыта — + строка кода или текста, а не заявление автора, — и заново проверяй + только AC, чьё доказательство дельта задевает. Разбор остаётся + ПОЛНЫМ, если дельта не локальна: ребейз на ушедший вперёд dev, + смена контракта, новая подсистема, объём сопоставим с задачей. + Сомневаешься — разбирай полностью и скажи почему. Сокращается + объём РАЗБОРА, а не строгость. + + Для этапа spec: ТЗ живёт в теле issue (#517) — читай его, а не файл. + Файлы docs/specs/-*.md — архив ТЗ до 2026-09-10: если такой файл + есть у старой задачи, он и есть материал, новые не создаются. + Проверь обязательные разделы §7.1, однозначность каждого AC и + указание способа доказательства. Утверждение о поведении, которого + нет ни в одном документе и которое не помечено как предположение, — + замечание. Не бывает сложной задачи без единого открытого вопроса. + Технический вопрос, вынесенный владельцу, — тоже замечание: ты его + снимаешь и решаешь по существу в своём вердикте. + + Для этапа code: материал — диапазон `git log --oneline origin/dev..HEAD` + и `git diff origin/dev...HEAD`. **Материал ревью — ровно + `${{ needs.prepare.outputs.material_sha }}`, рабочая копия уже на нём.** Не + делай `git fetch`, `git pull` и `git checkout` на другой коммит: вердикт + привязан к этому SHA (#312), и страж слияния сверяет вершину ветки с + ним. Если автор в issue называет более новый коммит, которого в + материале нет, — это находка «материал не был запушен до метки», а не + повод подтянуть его самому (#499). Ручного тестирования в цикле нет, + поэтому именно ты отвечаешь на вопрос «оно вообще работает». + По каждому AC: либо он доказан автотестом и ты убедился, что тест + умеет падать, либо разобран по коду с явной записью «проверено + чтением, не исполнением». Для защитного AC (валидация, гард, лимит, + отказ, инвариант) в документе обязательна строка таблицы + «AC · чем доказан · чем краснеет» с результатом прогона; пустой + третий столбец — находка Medium (§2.7, #435). «Verified» без + названной команды и её результата доказательством не является. + Проверь трейлеры Issue и User-Visible, при User-Visible: yes — правки + в оба changelog в том же коммите. Если дифф меняет величину, видимую + пользователю, назови прямо: какое число видно дважды и один ли у + него источник (§8). + + **Объём гейтов соразмерен задаче** (PROCESS.md §8): полные наборы — + предрелизный гейт, а не гейт ревью. + + ${{ needs.prepare.outputs.validated_note }} + + Если зелёного прогона на этом SHA нет — прогоняешь сам, они дешёвые, + и в повторном раунде тоже: `npx tsc --noEmit`, `npm test`, + `npm run build` со сверкой трёх копий бандла, плюс + `node scripts/check-docs.mjs`, если diff трогает `src/**`. + Зависимости уже установлены workflow, Chromium тоже — `npm ci` + выполнять не нужно. По диффу и AC: браузерные смоки — названные в AC + плюс вывод `node scripts/smoke-select.mjs --base --head `, + приложенный к комментарию с решением по каждой строке: прогнал либо + не прогнал и почему. Три вида ответа инструмента разные: «прямое + совпадение», «зарегистрированная связь», «НЕОПРЕДЕЛЁННОСТЬ» — связь + не доказана, и это не разрешение ничего не прогонять; слабые связи — + повод посмотреть, а не обязанность прогонять; + `npm run golden:verify` при видимом изменении; + `python -m pytest tests_backend -q` при правке + `custom_components/**/*.py`; инварианты модели + `npm run invariants -- --config <экспорт>` при правке геометрии или + ссылок на неё (#254) — задача меняет геометрию, а инварианты в + отчёте не названы, это непрогнанный гейт, а не мелочь; + performance-профили, если названы в AC. Дисциплина «тест должен + уметь падать» не отменяется, но применяется к тем тестам, которые + ты прогонял. + + **В комментарии обязателен перечень: какие гейты прогнал, какие нет и + почему.** Раздел «чего не проверял» в документе ревью — не + формальность, а главный его раздел на коротких задачах. + + Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь. + + Серьёзность: High блокирует; Medium В СКОУПЕ задачи чинится в ней + же — без High это жёлтый вердикт и возврат автору, отдельный issue + НЕ заводится (#202); Low либо правится, либо снимается с записью. + Жёлтый вердикт допустим и при полностью выполненных AC, если + изменение не решает заявленный сценарий или ухудшает смежный. + Продуктовое рассуждение расширяет вопросы, но не отменяет AC и не + даёт права менять скоуп. + + Только Medium-находку ВНЕ скоупа задачи (попутный дефект соседнего + поведения, который в этой ветке чинить нельзя) заведи отдельным + issue со ссылкой на #${{ github.event.issue.number }} и метками: + тип, приоритет, S1-new. «Оставили в тексте ревью» закрытием не + считается и прямо запрещено §12. + + Напиши полный документ ревью в файл, путь которого лежит в + переменной окружения REVIEW_DOC (абсолютный, ВНЕ репозитория). + Не в docs/reviews: восстановление дерева после проверки «умеет ли + тест падать» (`git checkout -- .`, `git clean -fd`) сносит + untracked-файл, и на #220 документ так исчезал три раунда подряд. + В репозиторий его положит шаг публикации, взяв из REVIEW_DOC; + тебе трогать docs/reviews не нужно. + + В самом репозитории не создавай файлов вообще: любые изменения в + рабочей копии будут отброшены. Имя документа в docs/reviews шаг + публикации соберёт сам — SPEC-REVIEW для этапа spec, CODE-REVIEW для + code, с номером issue и заходом. + + Содержание документа: скоуп, как проверялось, находки с + воспроизведением, что проверено и корректно, чего не проверял. Для + r2 и дальше добавь два раздела: «Закрытие раунда r» — таблица + «находка | чем закрыта | где это видно», и «Унаследовано из r» — + что принято без повторной проверки, с документом и SHA. + + Затем оставь в issue краткий комментарий: вердикт, ключевые находки + и ссылка на документ. Первой строкой — вердикт в формате §7.2: + `Вердикт: зелёный/жёлтый/красный · заход r${{ needs.guard.outputs.cycle }} · блокирующих циклов ${{ needs.guard.outputs.spent }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → в задаче | #…` + («→ #…» — только у Medium вне скоупа; находки в скоупе возвращаются автору жёлтым) + + Затем верни JSON по схеме. Это последнее действие и оно обязательно: + без него метка не переставится и конвейер встанет. + claude_args: | + --max-turns 150 + --allowedTools Read,Write,Grep,Glob,Bash,mcp__github__add_issue_comment,mcp__github__issue_write,mcp__github__issue_read + --json-schema '{"type":"object","properties":{"verdict":{"type":"string","enum":["green","yellow","red"]},"high":{"type":"integer"},"medium":{"type":"integer"},"summary":{"type":"string"}},"required":["verdict","high","medium","summary"]}' + + - name: Запечатать результат модели + id: result + env: + SOURCE: ${{ runner.temp }}/review-document.md + OUT: ${{ steps.review.outputs.structured_output }} + NUM: ${{ github.event.issue.number }} + STAGE: ${{ needs.guard.outputs.stage }} + CYCLE: ${{ needs.guard.outputs.cycle }} + run: | + marker=CODE-REVIEW + if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi + legacy="docs/reviews/${marker}-${NUM}-r${CYCLE}.md" + if [ ! -f "$SOURCE" ] && [ -f "$legacy" ]; then cp "$legacy" "$SOURCE"; fi + test -s "$SOURCE" || { echo "::error::модель не оставила документ ревью"; exit 1; } + printf '%s' "$OUT" > "$RUNNER_TEMP/verdict.json" + jq -e ' + (.verdict == "green" or .verdict == "yellow" or .verdict == "red") + and (.high | type == "number") and (.medium | type == "number") + and (.summary | type == "string")' "$RUNNER_TEMP/verdict.json" >/dev/null + dir="$RUNNER_TEMP/review-result" + mkdir -p "$dir" + cp "$RUNNER_TEMP/review-prepared/prepared.json" "$dir/prepared.json" + cp "$SOURCE" "$dir/review-document.md" + cp "$RUNNER_TEMP/verdict.json" "$dir/verdict.json" + (cd "$dir" && sha256sum prepared.json review-document.md verdict.json > manifest.sha256) + echo "artifact=review-result-${NUM}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" + + - name: Передать результат интеграции + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ${{ steps.result.outputs.artifact }} + path: ${{ runner.temp }}/review-result + if-no-files-found: error + retention-days: 1 + + - name: Зафиксировать длительность модели + id: duration + if: always() + env: + STARTED: ${{ steps.clock.outputs.started }} + run: | + seconds=$(( $(date +%s) - STARTED )) + echo "seconds=$seconds" >> "$GITHUB_OUTPUT" + echo "- model review: **${seconds}s**" >> "$GITHUB_STEP_SUMMARY" + + integrate: + name: "Ревью: публикация и интеграция" + # Публикует разбор, переставляет метку, сливает проверенный материал. + permissions: + contents: read + issues: write + needs: [guard, prepare, model_review] + if: always() && needs.guard.outputs.stage != '' + runs-on: ubuntu-latest + concurrency: + group: process-issue-${{ github.event.issue.number }} + cancel-in-progress: false + # Слияние кандидата при ушедшем dev может само ждать Validate до 45 минут; + # оно не должно обрывать уже оплаченный model review (#551). + timeout-minutes: 55 + steps: + - name: Начать измерение стадии + id: clock + run: echo "started=$(date +%s)" >> "$GITHUB_OUTPUT" + + - name: Проверить исходы предыдущих стадий + id: ready + env: + PREPARE_RESULT: ${{ needs.prepare.result }} + MODEL_RESULT: ${{ needs.model_review.result }} + PROCEED: ${{ needs.prepare.outputs.proceed }} + REUSE: ${{ needs.prepare.outputs.reuse }} + run: | + if [ "$PREPARE_RESULT" != "success" ]; then + echo "::error::стадия deterministic prerequisites завершилась: $PREPARE_RESULT" + exit 1 + fi + if [ "$PROCEED" = "pending" ]; then + echo "Validate на материале ещё идёт — раунд продолжит событие завершения (#636); интегрировать нечего" + echo "proceed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "$PROCEED" != "true" ]; then + echo "подготовка уже вернула задачу автору; интегрировать нечего" + echo "proceed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "$REUSE" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then + echo "::error::стадия model review завершилась: $MODEL_RESULT" + exit 1 + fi + echo "proceed=true" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + if: steps.ready.outputs.proceed == 'true' + with: + fetch-depth: 0 + ref: dev + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + if: steps.ready.outputs.proceed == 'true' + with: + node-version: 22 + + - name: Получить результат модели + if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: review-result-${{ github.event.issue.number }}-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/review-result + + - name: Проверить полноту и происхождение результата + id: result + if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' + # #556: единственное, что недоверенная стадия модели может передать + # дальше, — этот artifact, и принимается он как ввод противника: полный + # набор файлов, сходящиеся суммы, совпадение КАЖДОГО поля паспорта с + # тем, что посчитала детерминированная `prepare`. Проверка вынесена из + # inline-shell в `scripts/review-result-gate.mjs` ради враждебных + # фикстур — в YAML её нельзя прогнать ни одним отрицательным случаем. + env: + ISSUE: ${{ github.event.issue.number }} + STAGE: ${{ needs.guard.outputs.stage }} + CYCLE: ${{ needs.guard.outputs.cycle }} + BRANCH: ${{ needs.prepare.outputs.branch }} + MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }} + MATERIAL_TREE: ${{ needs.prepare.outputs.material_tree }} + MATERIAL_SPECS: ${{ needs.prepare.outputs.material_specs }} + MATERIAL_ISSUE_BODY: ${{ needs.prepare.outputs.material_issue_body }} + VALIDATE_RESULT: ${{ needs.prepare.outputs.validate_result }} + VALIDATE_URL: ${{ needs.prepare.outputs.validate_url }} + REBASE_NOTE: ${{ needs.prepare.outputs.rebase_note }} + VALIDATED_NOTE: ${{ needs.prepare.outputs.validated_note }} + SPEC_BODY_CHANGED: ${{ needs.prepare.outputs.spec_body_changed }} + SPEC_BODY_DOC: ${{ needs.prepare.outputs.spec_body_doc }} + SPEC_BODY_RECORDED: ${{ needs.prepare.outputs.spec_body_recorded }} + run: | + set -euo pipefail + dir="$RUNNER_TEMP/review-result" + node scripts/review-result-gate.mjs --dir="$dir" + printf 'structured_output<> "$GITHUB_OUTPUT" + cat "$dir/verdict.json" >> "$GITHUB_OUTPUT" + # structured_output не обязан оканчиваться LF: delimiter команды + # GitHub должен начинаться с отдельной строки. + printf '\nEOF_RESULT\n' >> "$GITHUB_OUTPUT" + + # Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит, + # решает этот шаг, а не модель: всё остальное откатывается. + - name: Опубликовать документ ревью + if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' + env: + TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + BRANCH: ${{ needs.prepare.outputs.branch }} + NUM: ${{ github.event.issue.number }} + STAGE: ${{ needs.guard.outputs.stage }} + CYCLE: ${{ needs.guard.outputs.cycle }} + SOURCE: ${{ runner.temp }}/review-result/review-document.md + # После ребейза конвейером — якоря приведённого материала (#515). + MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }} + MATERIAL_TREE: ${{ needs.prepare.outputs.material_tree }} + MATERIAL_SPECS: ${{ needs.prepare.outputs.material_specs }} + MATERIAL_ISSUE_BODY: ${{ needs.prepare.outputs.material_issue_body }} + # Вердикт из structured_output попадает в блок якорей (#499): по нему + # следующий заход решает, можно ли применить зелёный вердикт повторно. + OUT: ${{ steps.result.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. Раньше шаг в этом случае молча + # выходил с нулём, и разбор ревью терялся — оставался только вердикт + # комментарием. Это тот же тихий отказ: шаг сообщал об успехе тем, что + # ничего не сделал. Документ ложится туда же, где лежит само ТЗ. + target="${BRANCH:-dev}" + if [ -z "$BRANCH" ]; then + echo "::warning::ветки задачи нет — документ ревью ляжет в dev" + fi + marker=CODE-REVIEW + if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi + doc="docs/reviews/${marker}-${NUM}-r${CYCLE}.md" + # Документ спасается ПЕРВЫМ делом. Ревьюер мог написать его по старому + # пути прямо в рабочую копию, а дальше эта копия будет отброшена + # целиком — и вместе с ней пропал бы артефакт (#220). + if [ ! -f "$SOURCE" ] && [ -f "$doc" ]; then + cp "$doc" "$SOURCE" + echo "документ найден в рабочей копии и сохранён в $SOURCE" + fi + # Reset, а не checkout+clean, и вот почему (#365). + # + # 28.08 коммит bb2919f уехал в dev с тридцатью файлами вместо одного + # markdown: откатил отревьюженную реализацию #359, вернул старые чанки + # и держал dev откаченным три часа. Механизм воспроизведён: + # `git checkout -- .` восстанавливает рабочее дерево ИЗ ИНДЕКСА, а + # `git clean -fd` убирает неотслеживаемое — ни то, ни другое индекс не + # трогает. Ревьюер работает с Bash и в ходе проверки «умеет ли тест + # падать» вполне может сделать `git add`; всё, что осталось у него в + # индексе, прежняя уборка сохраняла, и следующий же `git commit` + # забирал это вместе с документом. Сообщение при этом невинное, и от + # рутины инцидент отличается только диффом. + # + # `reset --hard` снимает и индекс, и дерево разом. Терять нечего: + # документ приезжает извне репозитория, из RUNNER_TEMP. + git fetch -q origin "$target" + git reset -q --hard "origin/$target" + git clean -fdq -e node_modules >/dev/null 2>&1 || true + # Документ приезжает извне репозитория (#220). Три раунда подряд он + # терялся, пока лежал некоммитнутым файлом в том же дереве, которое + # ревьюер мутирует и затем восстанавливает: `git checkout -- .` плюс + # `git clean -fd` сносят собственный артефакт ревью, потому что он + # untracked. Теперь его место — RUNNER_TEMP, и уборка дерева ему не + # страшна. + if [ -f "$SOURCE" ]; then + mkdir -p docs/reviews + cp "$SOURCE" "$doc" + echo "документ взят из $SOURCE ($(wc -c < "$doc") байт)" + # Якоря дописывает конвейер, а не ревьюер (#414). Дисциплина здесь + # уже подводила: на #403 SHA сняли до ребейза и не сверили перед + # выводом — через раунд команда из §2.10 не работала. Машина же + # снимает якоря в момент чтения материала и ошибиться в них не + # может; блок помечен как машинный, чтобы никто не правил его руками. + node scripts/review-doc-guard.mjs --anchor="$doc" \ + --sha="$MATERIAL_SHA" --tree="$MATERIAL_TREE" \ + --branch="${BRANCH:-dev}" --specs="$MATERIAL_SPECS" \ + --issue-body="$MATERIAL_ISSUE_BODY" \ + --verdict="$verdict" --high="$high" + else + echo "::warning::$SOURCE не найден — документа для публикации нет" + fi + # Индексируется ровно один путь, а не каталог: `git add docs/reviews` + # забрал бы всё, что там окажется, а после reset там не должно быть + # ничего постороннего — но полагаться на «не должно» здесь нельзя. + git add -- "$doc" 2>/dev/null || true + # #635: индекс ревью пересобирается тем же коммитом, что и документ — + # иначе он устаревает на первом же раунде. Генерируемый файл, класс C. + if [ -f "$doc" ]; then + node scripts/reviews-index.mjs --dir=docs/reviews + git add -- docs/reviews/INDEX.md + fi + if git diff --cached --quiet; then + # Пустая рабочая копия — ещё не провал: ревьюер иногда коммитит + # документ сам, своим app-токеном мимо этого шага (CODE-REVIEW-150-r1, + # коммиттер GitHub). Провал — когда файла нет и на ветке. + git fetch -q origin "$target" + if git cat-file -e "origin/$target:$doc" 2>/dev/null; then + echo "документ уже опубликован ревьюером: $doc" + exit 0 + fi + # Ревью без артефакта запрещено (PROCESS.md §2.4/§10.4/§12). Раньше + # здесь стоял warning с exit 0: на #150 оба вердикта ревью ТЗ + # остались только комментариями, метки переставились, и пропажу + # заметило лишь следующее ревью — issue #171. Падение ДО шага с + # меткой сохраняет инвариант «метка не сменилась = прогон упал». + echo "::error::вердикт есть, а документа нет: ни $SOURCE, ни $doc в рабочей копии, ни $doc в $target — ревью без артефакта (#171, #220)" + exit 1 + fi + # Первый рубеж: что вообще проиндексировано. + git diff --cached --name-only | node scripts/review-doc-guard.mjs + git -c user.name="claude[bot]" \ + -c user.email="209825114+claude[bot]@users.noreply.github.com" \ + commit -q -F - </dev/null; then + echo "::error::коммит в $target опубликован, но ожидаемого $doc в нём нет — файл назван не по формату (#171)" + exit 1 + fi + echo "документ опубликован в $target: $doc" + + # Материал раунда обязан быть достижим с origin (#413). + # + # SPEC-REVIEW-403-r2 объявил материал на `HEAD = 83005c3c`, и тот же SHA + # независимо назвал автор ТЗ в комментарии issue. Коммит существовал, но + # к моменту публикации был осиротевшим: ветку перебазировали за 15 минут + # ДО публикации документа, спец-коммит переехал в 94502d3d с тем же + # сообщением и тем же содержимым. Через раунд команда `git diff + # 83005c3c..HEAD` из §2.10 буквально не работала, и r3 восстанавливал + # реальный коммит по содержимому диффа руками. + # + # Проверка стоит ПОСЛЕ публикации намеренно. Артефакт ревью терялся здесь + # трижды (#171, #220), и «вердикт без документа» в этом репозитории + # дороже мёртвой ссылки: документ сначала спасается, потом судится. Шаг + # при этом идёт ДО «Переставить метку», поэтому инвариант «метка не + # сменилась = прогон упал» сохраняется. + # + # Достижимость считается от `refs/remotes/origin/*`, а не от локальных + # ссылок: осиротевший 83005c3c до сих пор лежит в клоне автора и + # достижим там из необновлённой локальной ветки. Читателю отчёта от этого + # пользы нет — он достанет только то, что есть на origin. + - name: "Материал раунда воспроизводим (#413)" + if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' + env: + NUM: ${{ github.event.issue.number }} + STAGE: ${{ needs.guard.outputs.stage }} + CYCLE: ${{ needs.guard.outputs.cycle }} + BRANCH: ${{ needs.prepare.outputs.branch }} + run: | + marker=CODE-REVIEW + if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi + doc="docs/reviews/${marker}-${NUM}-r${CYCLE}.md" + target="${BRANCH:-dev}" + git fetch -q origin "$target" + # Судится опубликованная версия, а не рабочая копия: именно её прочтёт + # следующий раунд. + git show "origin/$target:$doc" | node scripts/review-doc-guard.mjs --doc=- + - name: Решение по вердикту + id: decide + if: steps.ready.outputs.proceed == 'true' + env: + OUT: ${{ steps.result.outputs.structured_output }} + STAGE: ${{ needs.guard.outputs.stage }} + REUSE: ${{ needs.prepare.outputs.reuse }} + REUSE_DOC: ${{ needs.prepare.outputs.reuse_doc }} + REUSE_ROUND: ${{ needs.prepare.outputs.reuse_round }} + REUSE_TREE: ${{ needs.prepare.outputs.reuse_tree }} + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + NUM: ${{ github.event.issue.number }} + run: | + 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" + + # Вперёд двигает ТОЛЬКО зелёный. Жёлтый и красный возвращают + # автору: на прогоне #111 жёлтый означал, что AC описывает неверное + # изменение контракта — реализовать такое ТЗ значит сделать ошибку + # по инструкции. Оба считаются циклом. + if [ "$verdict" = "green" ] && [ "$high" -eq 0 ]; then + green=true + case "$STAGE" in + spec) from=S4-spec-review; to=S5-ready ;; + code) from=S7-code-review; to=S8-merged ;; + esac + else + green=false + case "$STAGE" in + spec) from=S4-spec-review; to=S3-spec ;; + code) from=S7-code-review; to=S6-in-progress ;; + esac + fi + echo "green=$green" >> "$GITHUB_OUTPUT" + echo "from=$from" >> "$GITHUB_OUTPUT" + echo "to=$to" >> "$GITHUB_OUTPUT" + + # Ревью идёт десятки минут, а dev за это время двигается (28 августа — + # четыре раза за день). Вердикт при этом вынесен по дереву, которое уже не + # совпадает с вершиной линии, и слияние приведёт ветку к dev — то есть в + # dev уедет код, отличный от прочитанного (§7.2). Молчать об этом нельзя, + # но и шуметь на каждом прогоне ни к чему: строка появляется только когда + # dev действительно ушёл и вердикт зелёный, то есть слияние вот-вот + # случится (#364). + - name: dev ушёл вперёд, пока шло ревью + if: steps.ready.outputs.proceed == 'true' && needs.guard.outputs.stage == 'code' + env: + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + NUM: ${{ github.event.issue.number }} + MATERIAL: ${{ needs.prepare.outputs.material_sha }} + GREEN: ${{ steps.decide.outputs.green }} + run: | + git fetch -q origin dev + moved=$(git rev-list --count "$MATERIAL..origin/dev") + echo "dev продвинулся на $moved коммит(ов) с момента фиксации материала" + echo "- dev продвинулся на **$moved** коммит(ов) во время ревью" >> "$GITHUB_STEP_SUMMARY" + if [ "$moved" -eq 0 ] || [ "$GREEN" != "true" ]; then exit 0; fi + short=$(git rev-parse --short "$MATERIAL") + gh issue comment "$NUM" --repo "${{ github.repository }}" --body \ + "Пока шло ревью, \`dev\` продвинулся на $moved коммит(ов). Материал ревью — \`$short\`. Вердикт вынесен по дереву, которое уже не совпадает с вершиной линии: слияние приведёт ветку к dev, и это другой код (§7.2)." + + # 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: + HP_PROCESS_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + BRANCH: ${{ needs.prepare.outputs.branch }} + NUM: ${{ github.event.issue.number }} + MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }} + run: | + if [ -z "$BRANCH" ]; then + echo "::error::ветки задачи нет — сливать нечего" + echo "merged=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + node scripts/merge-candidate.mjs --branch="$BRANCH" --material="$MATERIAL_SHA" \ + --issue="$NUM" --repo="${{ github.repository }}" + + - name: Переставить метку + if: steps.ready.outputs.proceed == 'true' + env: + # Именно PAT: с GITHUB_TOKEN следующий шаг конвейера не запустится. + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + NUM: ${{ github.event.issue.number }} + FROM: ${{ steps.decide.outputs.from }} + # Зелёное код-ревью без слияния ведёт не в S8-merged, а обратно к + # автору: метка утверждала бы, что код в dev, а его там нет. + # Точный кандидат (#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" + echo "$FROM -> $TO" + + - name: Сводка длительности стадий + if: always() + env: + STARTED: ${{ steps.clock.outputs.started }} + PREPARE_SECONDS: ${{ needs.prepare.outputs.duration_seconds }} + MODEL_SECONDS: ${{ needs.model_review.outputs.duration_seconds }} + run: | + integration=$(( $(date +%s) - STARTED )) + { + echo "## Бюджеты стадий (#551)" + echo "" + echo "| Стадия | Длительность | Лимит |" + echo "|---|---:|---:|" + echo "| deterministic prerequisites | ${PREPARE_SECONDS:-нет полного измерения}s | 55 min |" + echo "| model review | ${MODEL_SECONDS:-не запускалась}s | 45 min |" + echo "| publication/integration | ${integration}s | 55 min |" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Позвать владельца, если стадия упала + if: failure() + env: + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + PREPARE_RESULT: ${{ needs.prepare.result }} + MODEL_RESULT: ${{ needs.model_review.result }} + PROCEED: ${{ needs.prepare.outputs.proceed }} + REUSE: ${{ needs.prepare.outputs.reuse }} + run: | + stage="публикация/интеграция" + detail="Модель уже завершила работу; её запечатанный результат сохранён artifact-ом этого run, но не был применён." + if [ "$PREPARE_RESULT" != "success" ]; then + stage="deterministic prerequisites" + detail="Модель не запускалась, цикл ревью не израсходован." + elif [ "$PROCEED" = "true" ] && [ "$REUSE" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then + stage="model review" + detail="Полного валидного результата модели нет; метка не менялась." + fi + # Тело через heredoc, а не многострочный --body: строка с нулевым + # отступом обрывает блок YAML и оставляет незакрытую кавычку. + cat > /tmp/failure.md <> "$GITHUB_OUTPUT" - echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT" - echo "ref=${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }}" >> "$GITHUB_OUTPUT" - # #620: самый свежий маркер зелёного прогона этого tree и этого workflow. - # Ключ уникален на прогон (кэш неизменяем), восстанавливается по префиксу — - # иначе перепроверка после недели не смогла бы освежить маркер. - - name: Маркер зелёного прогона этого дерева - if: github.event_name == 'schedule' - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 - with: - path: artifacts/mutation-green - key: mutation-green-v1-${{ steps.identity.outputs.tree }}-${{ github.workflow_sha }}-${{ github.run_id }} - restore-keys: | - mutation-green-v1-${{ steps.identity.outputs.tree }}-${{ github.workflow_sha }}- - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: 22 - # Любой сбой решения — полный прогон: пропуск должен быть доказан, а не - # выведен из ошибки. Материал без скрипта (dispatch старого ref) — тоже. - - name: Нужен ли прогон - id: reuse - env: - EVENT_NAME: ${{ github.event_name }} - TREE: ${{ steps.identity.outputs.tree }} - WORKFLOW_SHA: ${{ github.workflow_sha }} - RUN_URL_BASE: ${{ github.server_url }}/${{ github.repository }}/actions/runs - run: | - decision="$RUNNER_TEMP/mutation-reuse.txt" - if [ -f scripts/mutation-nightly-reuse.mjs ] && node scripts/mutation-nightly-reuse.mjs --decide \ - --event="$EVENT_NAME" --tree="$TREE" --workflow-sha="$WORKFLOW_SHA" \ - --marker=artifacts/mutation-green/marker.json \ - --summary="$GITHUB_STEP_SUMMARY" --run-url-base="$RUN_URL_BASE" > "$decision"; then - cat "$decision" >> "$GITHUB_OUTPUT" - if grep -qx 'reuse=true' "$decision"; then - echo "::notice::reused from run $(sed -n 's/^reused_run=//p' "$decision")" - fi - else - echo "::warning::решение о повторном использовании не получено — полный прогон" - echo "reuse=false" >> "$GITHUB_OUTPUT" - fi - - mutants: - name: "Мутанты: каждый обязан красить тесты (шард ${{ matrix.shard }} из 6)" - needs: material - if: needs.material.outputs.reuse != 'true' - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3, 4, 5, 6] - # Шесть чересполосных шардов (#604): при четырёх шард нёс ~203 мутанта из - # 810 и рос с реестром — 42 мин 10.09, 57 мин 20.09, 61 мин 21.09, и шард - # 2/4 был снят по потолку без единого FAIL. Делитель тот же, что у - # `changed_mutants` в Validate; число шардов повторяется в `--shard=i/6`, - # `--shards=6` и имени job — тест `mutation-gate.test.mjs` держит их - # равными. Бандл собирают только браузерные гварды. Час — потолок против - # зависшего Chromium, а не бюджет шарда: шард, упёршийся в него, — сигнал - # снова делить, и отчёт (#472) называет такой шард прерванным, не «ok». - timeout-minutes: 60 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: ${{ needs.material.outputs.sha }} - fetch-depth: 0 - - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: 22 - cache: npm - - - run: npm ci - - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 - with: - python-version: '3.14' - - - name: Установить backend test dependencies - run: | - # Точные версии — в tests_backend/requirements.txt (#392): без них - # резолвер выбирает набор сам, и «зелёный backend» значит разное - # в разные дни. - pip install -r tests_backend/requirements.txt - pip list --format=columns | grep -Ei 'homeassistant|voluptuous|^pytest ' - - - name: Кэш браузеров Playwright - id: pw - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} - - - name: Установить Chromium - if: steps.pw.outputs.cache-hit != 'true' - run: npx playwright install --with-deps chromium - - - name: Реестр применим к текущему коду - run: node scripts/mutation-gate.mjs --check - - - name: Тёплый test-build для инкрементальной компиляции мутантов - run: npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs - - # Вывод шарда сохраняется артефактом (#472): строки - # `FAIL : тест остался зелёным…` — единственное место, где названо, - # ЧТО сбежало. Без артефакта отказ безымянный. `PIPESTATUS` — чтобы - # `tee` не съел код выхода раннера. - - name: Каждый тест ловит свою поломку - id: gate - run: | - mkdir -p artifacts/mutation-shard-${{ matrix.shard }} - set -o pipefail - node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/6 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}/mutation-shard-${{ matrix.shard }}.log - # Исход шага едет в evidence (#604): снятый по timeout-minutes шаг даёт - # `cancelled`, и агрегатор отвергает такой шард как неполный — лог без - # строк FAIL сам по себе зелёным не считается. - - name: Записать identity шарда - if: always() - run: | - node scripts/mutation-gate-report.mjs \ - --write-evidence=artifacts/mutation-shard-${{ matrix.shard }}/evidence.json \ - --sha=${{ needs.material.outputs.sha }} \ - --tree=${{ needs.material.outputs.tree }} \ - --workflow-sha=${{ github.workflow_sha }} \ - --run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \ - --shard=${{ matrix.shard }} --shards=6 \ - --outcome=${{ steps.gate.outcome }} - - name: Сохранить лог и identity шарда - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: mutation-shard-${{ matrix.shard }}-attempt-${{ github.run_attempt }} - path: artifacts/mutation-shard-${{ matrix.shard }} - if-no-files-found: warn - retention-days: 30 - - # Результат нельзя приписывать material, пока не доказаны все шесть шардов — - # каждый с identity и с дошедшим до конца прогоном (#549, #604). - # always() нужен при красном мутанте: лог красного шарда всё равно evidence. - evidence: - name: "Доказать единый material всех шардов" - needs: [material, mutants] - if: always() && needs.material.outputs.reuse != 'true' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - # CLI отчётчика принадлежит зафиксированному material. В main лежит - # только workflow и может не быть dev-версии scripts/**. - ref: ${{ needs.material.outputs.sha }} - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: 22 - - name: Забрать evidence всех попыток - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - with: - pattern: mutation-shard-* - path: artifacts/mutation-logs - - name: Проверить полноту и identity - run: | - node scripts/mutation-gate-report.mjs --verify-only \ - --logs=artifacts/mutation-logs --shards=6 \ - --sha=${{ needs.material.outputs.sha }} \ - --tree=${{ needs.material.outputs.tree }} \ - --workflow-sha=${{ github.workflow_sha }} \ - --run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} - - # #620: маркер пишется ТОЛЬКО после зелёного агрегатора — все шесть шардов - # доказаны на одном material. Красный или неполный прогон маркера не оставляет, - # и следующая ночь гонит реестр заново. - green_marker: - name: "Записать маркер зелёного прогона" - needs: [material, mutants, evidence] - if: needs.material.outputs.reuse != 'true' && needs.mutants.result == 'success' && needs.evidence.result == 'success' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: ${{ needs.material.outputs.sha }} - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: 22 - - name: Маркер - run: | - node scripts/mutation-nightly-reuse.mjs \ - --write-marker=artifacts/mutation-green/marker.json \ - --tree=${{ needs.material.outputs.tree }} \ - --sha=${{ needs.material.outputs.sha }} \ - --workflow-sha=${{ github.workflow_sha }} \ - --run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \ - --event=${{ github.event_name }} - - name: Сохранить маркер - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 - with: - path: artifacts/mutation-green - key: mutation-green-v1-${{ needs.material.outputs.tree }}-${{ github.workflow_sha }}-${{ github.run_id }} - - # Адресат у отказа (#472). Только по расписанию: ручной dispatch остаётся - # для отладки самого гейта, его результат смотрят в прогоне — issue на - # каждый такой отказ был бы шумом, который снова перестанут читать. - # - # Права job-уровня ЗАМЕНЯЮТ права workflow, а не дополняют (прецедент — - # validate.yml, job с actions: read): перечислены все три. - report: - name: "Отказ расписания: issue и Telegram" - needs: [material, mutants, evidence] - # #620: ночь, принявшая доказательство прошлого зелёного прогона, отказом не - # является — её шарды пропущены намеренно. Любой другой пропуск шардов - # (упал material) по-прежнему заводит issue. - if: always() && github.event_name == 'schedule' && needs.material.outputs.reuse != 'true' && (needs.mutants.result != 'success' || needs.evidence.result != 'success') - runs-on: ubuntu-latest + # Потолок прав тела: объединение job-level прав `_mutation-gate.yml`. Вызываемый + # workflow может права только сузить, поэтому каждая его job по-прежнему + # получает свой прежний минимум (#556), а шире этого набора не получит никто. + dev: permissions: contents: read actions: read issues: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - # Не перечитываем moving dev: код отчётчика берётся из уже - # зафиксированного material. В стабильном main dev-скриптов нет. - ref: ${{ needs.material.outputs.sha }} - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: 22 - - name: Забрать логи шардов - continue-on-error: true - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # 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: | - mkdir -p artifacts - node scripts/mutation-gate-report.mjs \ - --require-evidence --logs=artifacts/mutation-logs --shards=6 \ - --sha=${{ needs.material.outputs.sha }} \ - --tree=${{ needs.material.outputs.tree }} \ - --workflow-sha=${{ github.workflow_sha }} \ - --run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \ - --run-url="$RUN_URL" --ref=${{ needs.material.outputs.ref }} \ - --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 + uses: Matysh/houseplan-card/.github/workflows/_mutation-gate.yml@dev # #623: тело конвейера из dev + with: + ref: ${{ inputs.ref }} + secrets: inherit diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index f52efc46..af2f6caa 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -1,25 +1,13 @@ -# Ночной полный прогон (#479). -# -# Тяжёлые job Validate — смоки, golden, performance_smoke — на обычном пуше не -# идут: они ни разу не ловили дефект в момент ревью и стоили ~6 минут -# критического пути на каждую итерацию. Полный набор идёт на кандидате беты -# (трейлер `Release:`), по кнопке и здесь — каждую ночь на голове `dev`. -# -# Почему не `schedule` прямо в validate.yml: расписание исполняется на ветке -# по умолчанию (`main`), а проверять надо `dev`. Один dispatch с `--ref dev` -# делает это без переписывания checkout во всех job. Reuse (#208) сохраняется: -# при неизменённом дереве ночной прогон обойдётся маркерами. -# -# Красный ночной прогон — сигнал автору последних коммитов на dev, не гейт: -# гейт беты по-прежнему требует зелёный Validate на точном SHA кандидата, и -# там полный набор идёт заново. -# -# Сигнал обязан быть настоящим (#492 §7): до этой задачи job завершалась -# успехом в момент постановки Validate в очередь, и красный полный прогон не -# делал ночной workflow красным. Теперь job находит запущенный прогон и ждёт -# его: успешный dispatch — не успешная проверка. name: Ночной полный прогон dev +# Тонкий вызывающий файл (#623). Для этого события GitHub берёт workflow из +# ветки по умолчанию (`main`), поэтому здесь только то, что обязано жить там: +# триггеры, run-name, права и concurrency. Тело — `_nightly.yml` по ссылке +# `@dev`: правка конвейера — один коммит в `dev`, зеркало в `main` не нужно. +# Этот файл меняется, только когда меняются сами триггеры или потолок прав; +# тогда он зеркалится в `main`, и preflight `workflow_sync` (validate.yml) +# держит копии равными. + on: schedule: - cron: '30 2 * * *' @@ -30,35 +18,12 @@ permissions: contents: read jobs: - dispatch: - 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) поставлен в очередь: $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 + # Потолок прав тела: объединение job-level прав `_nightly.yml`. Вызываемый + # workflow может права только сузить, поэтому каждая его job по-прежнему + # получает свой прежний минимум (#556), а шире этого набора не получит никто. + dev: + permissions: + actions: write + contents: read + uses: Matysh/houseplan-card/.github/workflows/_nightly.yml@dev # #623: тело конвейера из dev + secrets: inherit diff --git a/.github/workflows/process-metrics.yml b/.github/workflows/process-metrics.yml index dfbdc10c..0c9d10dc 100644 --- a/.github/workflows/process-metrics.yml +++ b/.github/workflows/process-metrics.yml @@ -1,10 +1,12 @@ name: Метрики процесса -# #637: еженедельный замер вместо ощущений — lead time S→S7→S8, раунды ревью, -# прогоны Validate по исходам, минуты конвейера в S4/S7. Только чтение: отчёт -# идёт в step summary и артефакт; в issue и репозиторий ничего не пишется. -# Цифры аудита 22.09 (1,57 раунда код-ревью, S7 ≈ 36 мин/раунд, 226 Validate -# за неделю) были собраны руками за час — теперь они стоят один запуск. +# Тонкий вызывающий файл (#623). Для этого события GitHub берёт workflow из +# ветки по умолчанию (`main`), поэтому здесь только то, что обязано жить там: +# триггеры, run-name, права и concurrency. Тело — `_process-metrics.yml` по ссылке +# `@dev`: правка конвейера — один коммит в `dev`, зеркало в `main` не нужно. +# Этот файл меняется, только когда меняются сами триггеры или потолок прав; +# тогда он зеркалится в `main`, и preflight `workflow_sync` (validate.yml) +# держит копии равными. on: schedule: @@ -24,37 +26,15 @@ permissions: issues: read jobs: - metrics: - name: "Снимок недели: issue, раунды, прогоны" - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - # Код — из dev, как у reconcile: расписание читается из main, а исполняется - # версия, которую проверил CI. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: dev - fetch-depth: 1 - persist-credentials: false - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: 22 - - name: Собрать отчёт - env: - GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} - REPO: ${{ github.repository }} - DAYS: ${{ inputs.days || '7' }} - run: | - mkdir -p artifacts/process-metrics - node scripts/process-metrics.mjs --repo="$REPO" --days="$DAYS" \ - --output=artifacts/process-metrics/report.md \ - --json=artifacts/process-metrics/report.json > /dev/null - cat artifacts/process-metrics/report.md >> "$GITHUB_STEP_SUMMARY" - - name: Сохранить отчёт - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: process-metrics-${{ github.run_id }} - path: artifacts/process-metrics - if-no-files-found: error - retention-days: 90 + # Потолок прав тела: объединение job-level прав `_process-metrics.yml`. Вызываемый + # workflow может права только сузить, поэтому каждая его job по-прежнему + # получает свой прежний минимум (#556), а шире этого набора не получит никто. + dev: + permissions: + contents: read + actions: read + issues: read + uses: Matysh/houseplan-card/.github/workflows/_process-metrics.yml@dev # #623: тело конвейера из dev + with: + days: ${{ inputs.days }} + secrets: inherit diff --git a/.github/workflows/process-reconcile.yml b/.github/workflows/process-reconcile.yml index f9d3565b..87239acc 100644 --- a/.github/workflows/process-reconcile.yml +++ b/.github/workflows/process-reconcile.yml @@ -1,6 +1,14 @@ name: Сверка очереди ревью run-name: "reconcile process queue · ${{ github.event_name }}" +# Тонкий вызывающий файл (#623). Для этого события GitHub берёт workflow из +# ветки по умолчанию (`main`), поэтому здесь только то, что обязано жить там: +# триггеры, run-name, права и concurrency. Тело — `_process-reconcile.yml` по ссылке +# `@dev`: правка конвейера — один коммит в `dev`, зеркало в `main` не нужно. +# Этот файл меняется, только когда меняются сами триггеры или потолок прав; +# тогда он зеркалится в `main`, и preflight `workflow_sync` (validate.yml) +# держит копии равными. + on: schedule: - cron: '7,37 * * * *' @@ -18,41 +26,15 @@ permissions: issues: read jobs: - reconcile: - name: "Один снимок S4/S7 без polling модели" - runs-on: ubuntu-latest - timeout-minutes: 10 - concurrency: - group: process-reconcile - cancel-in-progress: false - steps: - # Расписание читается из main, а исполняемый reconciler — из dev: так - # после штатного merge действует та же версия кода, которую проверил CI. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: dev - fetch-depth: 1 - persist-credentials: false - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: 22 - - name: Сопоставить labels, requests, runs и sealed evidence - env: - GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} - REPO: ${{ github.repository }} - APPLY: ${{ github.event_name == 'schedule' || inputs.apply == true }} - run: | - mkdir -p artifacts/process-reconcile - node scripts/process-reconcile.mjs \ - --repo "$REPO" \ - --apply="$APPLY" \ - --max-actions=5 \ - --output=artifacts/process-reconcile/summary.json - - name: Опубликовать компактный machine-readable итог - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: process-reconcile-${{ github.run_id }}-${{ github.run_attempt }} - path: artifacts/process-reconcile/summary.json - if-no-files-found: error - retention-days: 14 + # Потолок прав тела: объединение job-level прав `_process-reconcile.yml`. Вызываемый + # workflow может права только сузить, поэтому каждая его job по-прежнему + # получает свой прежний минимум (#556), а шире этого набора не получит никто. + dev: + permissions: + actions: read + contents: read + issues: read + uses: Matysh/houseplan-card/.github/workflows/_process-reconcile.yml@dev # #623: тело конвейера из dev + with: + apply: ${{ inputs.apply == true }} + secrets: inherit diff --git a/.github/workflows/process-resume.yml b/.github/workflows/process-resume.yml index e26cf38a..f05831a4 100644 --- a/.github/workflows/process-resume.yml +++ b/.github/workflows/process-resume.yml @@ -1,19 +1,13 @@ name: Продолжение ревью после Validate run-name: "resume · ${{ github.event.workflow_run.head_branch }} · ${{ github.event.workflow_run.conclusion }}" -# #636. Стадия `prepare` конвейера (process.yml) больше не ждёт Validate с -# мутантами на материале внутри job — раннер спал ≈ 28 минут на раунд при -# 10–12 минутах работы модели. Она диспатчит прогон, кладёт запечатанный -# маркер `review-pending-…` и выходит. Этот workflow просыпается на завершение -# любого Validate и, если раунд ждал именно этот прогон (маркер на материале, -# метка S7 стоит, активного прогона конвейера нет), переставляет метку S7 — -# новый прогон `prepare` находит завершённый dispatch и продолжает раунд. -# Ничего не оценивает: зелёный/красный разбирает сам конвейер. Страховка на -# потерянное событие — process-reconcile.yml с тем же маркером. -# -# Как и process.yml, файл исполняется из ветки по умолчанию (main): для -# события `workflow_run` GitHub берёт workflow только оттуда. Сверка копий — -# в preflight validate.yml. +# Тонкий вызывающий файл (#623). Для этого события GitHub берёт workflow из +# ветки по умолчанию (`main`), поэтому здесь только то, что обязано жить там: +# триггеры, run-name, права и concurrency. Тело — `_process-resume.yml` по ссылке +# `@dev`: правка конвейера — один коммит в `dev`, зеркало в `main` не нужно. +# Этот файл меняется, только когда меняются сами триггеры или потолок прав; +# тогда он зеркалится в `main`, и preflight `workflow_sync` (validate.yml) +# держит копии равными. on: workflow_run: @@ -25,36 +19,15 @@ permissions: actions: read jobs: - resume: - name: "Разбудить раунд, ждавший этот Validate" + # Потолок прав тела: объединение job-level прав `_process-resume.yml`. Вызываемый + # workflow может права только сузить, поэтому каждая его job по-прежнему + # получает свой прежний минимум (#556), а шире этого набора не получит никто. + dev: + # Тот же фильтр, что у job `resume` тела: посторонние события не + # поднимают вызов, а прогон остаётся skipped, как до #623. if: github.event.workflow_run.event == 'workflow_dispatch' && startsWith(github.event.workflow_run.head_branch, 'issue/') - runs-on: ubuntu-latest - timeout-minutes: 10 - concurrency: - group: process-resume-${{ github.event.workflow_run.head_branch }} - cancel-in-progress: false - steps: - # Код берётся из dev, как у reconcile: после штатного слияния действует - # версия, которую проверил CI, а не копия из main. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: dev - fetch-depth: 1 - persist-credentials: false - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: 22 - # Метка переставляется HP_PROCESS_TOKEN: событие от GITHUB_TOKEN не - # запустило бы process.yml (см. шапку process.yml, п. 1). - - name: Решить по маркеру ожидания и переставить S7 - env: - GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} - REPO: ${{ github.repository }} - BRANCH: ${{ github.event.workflow_run.head_branch }} - SHA: ${{ github.event.workflow_run.head_sha }} - EVENT: ${{ github.event.workflow_run.event }} - STATUS: ${{ github.event.workflow_run.status }} - run: | - node scripts/process-resume.mjs \ - --repo="$REPO" --branch="$BRANCH" --sha="$SHA" \ - --event="$EVENT" --status="$STATUS" --apply=true | tee -a "$GITHUB_STEP_SUMMARY" + permissions: + contents: read + actions: read + uses: Matysh/houseplan-card/.github/workflows/_process-resume.yml@dev # #623: тело конвейера из dev + secrets: inherit diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index efd9d825..c74c18ec 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -1,1646 +1,31 @@ name: Ревью-конвейер run-name: "process #${{ github.event.issue.number }} · ${{ github.event.label.name }} · ${{ github.event.issue.title }}" -# Событийный конвейер процесса (PROCESS.md). Смена статусной метки — это -# сообщение: она порождает событие, событие запускает следующий шаг. -# -# S4-spec-review -> ревью ТЗ -> S5-ready | S3-spec -# S7-code-review -> код-ревью -> слияние в dev -> S8-merged | S6-in-progress -# -# Три вещи, без которых конвейер молча не работает: -# -# 1. Метки переставляются токеном HP_PROCESS_TOKEN, а не GITHUB_TOKEN. GitHub -# намеренно не запускает workflow от событий, вызванных GITHUB_TOKEN, чтобы -# не было циклов — цепочка оборвалась бы после первого шага. -# 2. Этот файл обязан лежать в ветке по умолчанию (main). Для события `issues` -# GitHub берёт workflow только оттуда, независимо от того, что в dev. -# 3. Многострочный текст внутри `run:` — только через heredoc. Строка с нулевым -# отступом обрывает блок YAML, и скрипт обрезается без ошибки парсера. -# Проверять не только YAML, но и каждый `run` через `bash -n`. +# Тонкий вызывающий файл (#623). Для этого события GitHub берёт workflow из +# ветки по умолчанию (`main`), поэтому здесь только то, что обязано жить там: +# триггеры, run-name, права и concurrency. Тело — `_process.yml` по ссылке +# `@dev`: правка конвейера — один коммит в `dev`, зеркало в `main` не нужно. +# Этот файл меняется, только когда меняются сами триггеры или потолок прав; +# тогда он зеркалится в `main`, и preflight `workflow_sync` (validate.yml) +# держит копии равными. on: issues: types: [labeled] -# Concurrency стоит на job, а не на workflow (#499). На уровне workflow в -# группу issue попадал КАЖДЫЙ прогон — и от `polish`, и от `P2`, и от метки, -# которую переставил сам конвейер. GitHub держит в группе один идущий и один -# ожидающий прогон, и новый ожидающий вытесняет старого: ожидавший S7-code-review -# отменялся первой же посторонней меткой. Теперь посторонняя метка не запускает -# ни одной job (`if` на guard) и в группу не входит. - -# #556: права выдаются по job, а не одной строкой на весь workflow. Общий блок -# давал `issues: write` и OIDC даже той стадии, которая ничего не пишет, — -# модели. Ниже минимум на каждую: писать в issue умеют только детерминированные -# стадии, OIDC нужен исключительно `claude-code-action`. permissions: contents: read jobs: - guard: - name: "Страж: ребейз на dev и предпосылки ревью" - # Читает и переставляет метки, комментирует отказ. - permissions: - contents: read - issues: write - # Только статусные метки этапов ревью запускают конвейер (#499). Остальные - # события помечаются skipped и не занимают место в группе concurrency. + # Потолок прав тела: объединение job-level прав `_process.yml`. Вызываемый + # workflow может права только сузить, поэтому каждая его job по-прежнему + # получает свой прежний минимум (#556), а шире этого набора не получит никто. + dev: + # Тот же фильтр, что у job `guard` тела: посторонние события не + # поднимают вызов, а прогон остаётся skipped, как до #623. 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 }} - spent: ${{ steps.decide.outputs.spent }} - limit: ${{ steps.decide.outputs.limit }} - steps: - # Мелкий checkout: guard остаётся лёгким, но ему нужен - # scripts/review-doc-guard.mjs — счёт раундов вынесен туда, потому что - # inline-shell не покрывается тестами (#454). Node на раннере - # предустановлен, setup-node не нужен. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 1 - ref: dev - persist-credentials: false - - id: decide - env: - GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} - REPO: ${{ github.repository }} - LABEL: ${{ github.event.label.name }} - 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 - S4-spec-review) stage="spec"; marker="SPEC-REVIEW" ;; - 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 - if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi - - # Считаются ДВЕ РАЗНЫЕ величины, и это не педантизм (#227). - # - # `attempt` — сколько раз ревью уже отработало на этом этапе. Он нужен - # только для имени документа и метки: два захода с одинаковым номером - # означают, что второй документ перезапишет первый и артефакт ревью - # исчезнет. - # - # `spent` — сколько циклов израсходовано из бюджета §4. Цикл — это - # «отправка на ревью → вердикт с блокирующими находками → возврат - # автору», поэтому бюджет тратят ТОЛЬКО жёлтые и красные вердикты. - # Зелёный ничего на правки не вернул и цикла не образует. - # - # Раньше обе роли исполнял один счётчик всех вердиктов, и конвейер - # наказывал за то, что предписывал сам: при неудавшемся слиянии он - # велит вернуть S7-code-review после ребейза, и этот заход добивал - # бюджет. На #225 (лёгкий трек, лимит 2) последовательность - # жёлтый → зелёный → ребейз дала review-4 на задаче с зелёным ревью и - # зелёным CI: работа встала, хотя после вердикта не было ни одной - # правки продуктового кода. - # - # Вердикты считаются ТОЛЬКО своего этапа: иначе вердикт по ТЗ съедал - # цикл из бюджета код-ревью (#89 получило r2/4). Этап опознаётся по - # имени документа — раньше по подстроке маркера в теле комментария, - # теперь по имени документа ЭТОЙ задачи, `-`: голая - # подстрока протекала на прозе. #454 поймала это на себе — разбор - # чужих задач в комментарии содержал `CODE-REVIEW`, и первый же - # код-ревью получил заход r3. - # - # Счёт по комментариям остаётся ровно тем же, но он БОЛЬШЕ НЕ - # ЕДИНСТВЕННЫЙ (#454). Маркер этапа попадает в тело комментария, - # только если ревьюер сам назвал имя файла, — то есть прежний счёт - # зависел от формулировки. На #449 первый спек-вердикт файла не - # назвал, заход r2 получил номер r1, и документ второго раунда лёг - # ПОВЕРХ документа первого. Оценка «недосчёт обратим» была неверна - # ровно здесь: номер захода входит в имя файла, и повтор номера — не - # лишний заход, а потеря артефакта. - # - # Поэтому рядом встаёт второй источник — опубликованные документы: - # их имена несёт сам конвейер, и подделать их прозой нельзя. Берётся - # МАКСИМУМ двух источников: недосчёт возможен только при отказе - # обоих, перерасчёт невозможен по построению. - attempt=1; spent=0; spent_list="" - if [ -n "$stage" ]; then - comments=$(mktemp) - gh issue view "$NUM" --repo "$REPO" --json comments > "$comments" - - # Ветка задачи — та же, что выберет шаг ревью: свежая по коммиту. - # Её нет у задач, размеченных до появления конвейера; тогда счёт по - # файлам даёт ноль и работает страховка по комментариям. - branch=""; newest="" - for ref in $(gh api "repos/$REPO/git/matching-refs/heads/issue/$NUM-" \ - --jq '.[].ref' 2>/dev/null | sed 's|^refs/heads/||'); do - date=$(gh api "repos/$REPO/commits/$ref" --jq '.commit.committer.date' 2>/dev/null || true) - if [ -n "$date" ] && { [ -z "$newest" ] || [ "$date" \> "$newest" ]; }; then - newest="$date"; branch="$ref" - fi - done - target="${branch:-dev}" - names=$(mktemp); docs=$(mktemp -d) - # #621: каталог перечисляется Git Trees API, а не `contents`. - # У `contents` потолок 1 000 записей, после которого ответ молча - # обрезается; docs/reviews подошёл к нему (986 файлов на 23.09, - # +≈60 в неделю), и счёт по файлам начал бы занижаться — то есть - # повторять номер захода и класть документ поверх предыдущего. - # Дерево спускается по одному уровню: commit → root → docs → - # reviews; лимит дерева — 100 000 записей, флаг `truncated` здесь - # трактуется как отказ, а не как частичный список. - tree_names() { - local sha entry - sha=$(gh api "repos/$REPO/commits/$1" --jq '.commit.tree.sha') || return 1 - for entry in docs reviews; do - sha=$(gh api "repos/$REPO/git/trees/$sha" \ - --jq ".tree[] | select(.type == \"tree\" and .path == \"$entry\") | .sha") || return 1 - [ -n "$sha" ] || return 1 - done - gh api "repos/$REPO/git/trees/$sha" \ - --jq 'if .truncated then error("truncated") else .tree[] | select(.type == "blob") | .path end' - } - if ! tree_names "$target" > "$names" 2>/dev/null; then - : > "$names" - echo "::warning::дерево docs/reviews на $target не получено — счёт по файлам отключён" - fi - # Тела нужны только своим документам этапа: их единицы. - for name in $(grep -E "^${marker}-${NUM}-r[0-9]+\\.md$" "$names" || true); do - gh api "repos/$REPO/contents/docs/reviews/$name?ref=$target" \ - -H 'Accept: application/vnd.github.raw' > "$docs/$name" 2>/dev/null || rm -f "$docs/$name" - done - list=$(mktemp) - counters=$(node scripts/review-doc-guard.mjs --counters \ - --marker="$marker" --num="$NUM" --names="$names" --docs="$docs" \ - --comments="$comments" --spent-list="$list") - spent_list=$(cat "$list") - # Пустой ответ означает, что скрипт не отработал. Тогда остаются - # значения по умолчанию (заход 1, циклов 0): guard обязан - # продолжить работу, а не встать. - new_attempt=$(printf '%s\n' "$counters" | sed -n 's/^attempt=//p') - new_spent=$(printf '%s\n' "$counters" | sed -n 's/^spent=//p') - new_blocking=$(printf '%s\n' "$counters" | sed -n 's/^blocking=//p') - case "$new_attempt" in ''|*[!0-9]*) echo "::warning::счётчик раундов не дал числа — работают значения по умолчанию" ;; *) attempt="$new_attempt" ;; esac - case "$new_spent" in ''|*[!0-9]*) : ;; *) spent="$new_spent" ;; esac - # Перечень учтённого обязан сходиться с числом: если цикл виден - # только документом, ссылка на комментарий его не объяснит. - if [ -n "$new_blocking" ]; then - spent_list="$spent_list - - документы: $new_blocking" - fi - echo "ветка материала: ${branch:-нет, читался dev}" - fi - - # Отказ обязан быть виден в issue, а не только в логе прогона. - # Ревьюшная метка обещает работу; если конвейер её не начал и промолчал, - # задача стоит в этом статусе бесконечно и никто об этом не узнаёт. - # Так и вышло на #123: чужой issue довели до S4-spec-review, guard - # отказался за 9 секунд, и в issue не было ни слова. - # - # Пишем только когда пытались запустить ревью, то есть stage опознан. - # Иначе комментарий уходил бы на каждую смену любой метки. - refuse() { - echo "$1" - gh issue comment "$NUM" --repo "${{ github.repository }}" --body \ - "Конвейер ревью не запущен: $2 - - Метка \`$LABEL\` обещает работу, которая не начнётся, поэтому статус лучше вернуть в предыдущий — иначе задача простоит здесь бесконечно. [Прогон](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})." - stage="" - } - - # Автор issue здесь не проверяется (решение владельца 2026-08-13). - # Проверка стоит на входе в процесс, а не на каждом шаге: как только - # задача получила статусную метку, она в работе, и кто её завёл — не - # имеет значения. Само присвоение метки и есть явное подтверждение - # владельца, причём проверенное платформой: метки может ставить только - # тот, у кого есть право записи в репозиторий. Прежняя проверка здесь - # дублировала эту гарантию и заставляла переоформлять чужие отчёты - # своими issue — чистая работа впустую, как на #123. - if [ -z "$stage" ]; then - : - elif [ "$BLOCKED" = "true" ]; then - refuse "стоит blocked — конвейер не запускается" \ - "на issue стоит \`blocked\` — задача ждёт внешнего решения. Снять метку, когда решение принято." - elif [ "$EXHAUSTED" = "true" ]; then - # Метку снимает владелец, а не конвейер: автоматика, отменяющая - # остановку работы, дороже ручного снятия. Но пересчёт печатается — - # метка могла остаться от прежнего правила, когда бюджет тратил и - # зелёный вердикт (#227). - stale="" - if [ "$spent" -lt "$limit" ]; then - stale=" Пересчёт по действующему правилу: блокирующих циклов $spent из $limit — метка могла остаться от прежнего правила, когда бюджет тратил любой вердикт. Снять её может владелец." - fi - refuse "стоит review-4 — решение за владельцем" \ - "на issue стоит \`review-4\`: лимит циклов ревью исчерпан, дальше решает владелец — разделить задачу, отклонить или арбитраж (PROCESS.md §4).$stale" - elif [ "$spent" -ge "$limit" ]; then - echo "блокирующих циклов этапа $stage: $spent из $limit — лимит исчерпан" - gh issue edit "$NUM" --repo "${{ github.repository }}" --add-label review-4 - # Перечень учтённого обязателен: иначе владельцу приходится читать - # всю ленту, чтобы понять, из чего сложился счёт. - gh issue comment "$NUM" --repo "${{ github.repository }}" --body \ - "Лимит циклов ревью исчерпан: блокирующих циклов $spent из $limit на этапе \`$stage\` (заход $attempt). Следующего захода нет: решение владельца — разделить задачу, отклонить или арбитраж (PROCESS.md §4). - - Учтены вердикты с блокирующими находками — зелёные бюджет не тратят: - $spent_list" - stage="" - else - echo "этап $stage, заход $attempt, блокирующих циклов $spent из $limit" - fi - echo "stage=$stage" >> "$GITHUB_OUTPUT" - echo "cycle=$attempt" >> "$GITHUB_OUTPUT" - echo "spent=$spent" >> "$GITHUB_OUTPUT" - echo "limit=$limit" >> "$GITHUB_OUTPUT" - - prepare: - name: "Ревью: материал и deterministic gates" - # Ребейзит ветку задачи и комментирует возврат автору. permissions: contents: read issues: write - needs: guard - if: needs.guard.outputs.stage != '' - runs-on: ubuntu-latest - concurrency: - group: process-issue-${{ github.event.issue.number }} - cancel-in-progress: false - # Ожидание Validate не отнимает бюджет у модели (#551). Сам gate может - # ждать 45 минут; подготовке оставлен отдельный запас на checkout/rebase. - timeout-minutes: 55 - outputs: - proceed: ${{ steps.gate.outputs.proceed }} - branch: ${{ steps.branch.outputs.name }} - rebase_note: ${{ steps.rebase.outputs.note }} - material_sha: ${{ steps.material.outputs.sha }} - material_tree: ${{ steps.material.outputs.tree }} - material_specs: ${{ steps.material.outputs.specs }} - material_issue_body: ${{ steps.material.outputs.issue_body }} - reuse: ${{ steps.reuse.outputs.reuse }} - reuse_doc: ${{ steps.reuse.outputs.doc }} - reuse_round: ${{ steps.reuse.outputs.round }} - reuse_tree: ${{ steps.reuse.outputs.tree }} - validate_result: ${{ steps.gate.outputs.result }} - validate_url: ${{ steps.gate.outputs.url }} - validated_note: ${{ steps.validated.outputs.note }} - spec_body_changed: ${{ steps.spec_body.outputs.changed }} - spec_body_doc: ${{ steps.spec_body.outputs.doc }} - spec_body_recorded: ${{ steps.spec_body.outputs.recorded }} - duration_seconds: ${{ steps.duration.outputs.seconds }} - steps: - - name: Начать измерение стадии - id: clock - run: echo "started=$(date +%s)" >> "$GITHUB_OUTPUT" - - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - ref: dev - # Иначе в конфиге git остаётся креденшел GITHUB_TOKEN, и push с - # мёртвым PAT молча уходит от github-actions[bot] — 403 при - # contents: read. Отказ обязан быть громким и правильным. - persist-credentials: false - - # Живость PAT проверяется ДО ревью. На #150 истёкший токен обнаружился - # только на публикации документа — после сорока минут работы ревьюера. - - name: Секрет HP_PROCESS_TOKEN жив - env: - GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} - run: | - if [ -z "$GH_TOKEN" ]; then - echo "::error::HP_PROCESS_TOKEN пуст — секрет удалён или недоступен" - exit 1 - fi - if ! login=$(gh api user -q .login 2>/dev/null); then - echo "::error::HP_PROCESS_TOKEN не аутентифицируется — истёк или отозван. Обновить: Settings -> Secrets and variables -> Actions -> HP_PROCESS_TOKEN" - exit 1 - fi - echo "токен жив, действует от: $login" - - # Окружение готовит workflow, а не модель своими ходами. Раньше промпт - # велел ревьюеру самому выполнить `npm ci`: минуты уходили на установку без - # кэша, платились из бюджета 45 минут и из лимитов подписки, а ходы модели - # тратились на работу инфраструктуры. В validate.yml кэш стоит на всех - # тяжёлых job, здесь его не было. - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: 22 - cache: npm - - # Материал ревью живёт в ветке задачи: ТЗ в docs/specs/ и код коммитятся - # в issue/-slug. Если ветка запушена — переключаемся на неё, иначе - # ревьюер прочтёт dev и не найдёт того, что должен оценивать. - - name: Перейти на ветку задачи - id: branch - env: - NUM: ${{ github.event.issue.number }} - run: | - # Свежая по последнему коммиту, а не первая по алфавиту: на #150 рядом - # жили ветка ТЗ и ветка реализации, и head -1 выбрал устаревшую. - git fetch -q origin "+refs/heads/issue/${NUM}-*:refs/remotes/origin/issue/${NUM}-*" || true - branches=$(git for-each-ref --sort=-committerdate \ - --format='%(refname:lstrip=3)' "refs/remotes/origin/issue/${NUM}-*") - branch=$(printf '%s\n' "$branches" | head -1) - if [ "$(printf '%s\n' "$branches" | grep -c .)" -gt 1 ]; then - echo "::warning::веток issue/${NUM}-* несколько ($(echo $branches | tr '\n' ' ')) — выбрана свежая по коммиту: $branch. Устаревшую следует удалить." - fi - if [ -n "$branch" ]; then - git checkout -q "origin/$branch" - echo "материал ревью: ветка $branch, $(git rev-parse --short HEAD)" - echo "name=$branch" >> "$GITHUB_OUTPUT" - # Якоря материала (sha, tree, specs) снимает шаг `material` — ПОСЛЕ - # ребейза (#515): снятые здесь, они после force-push приведённой - # ветки указывали на осиротевший коммит, и ни reuse (#499), ни - # страховка #414 не находили дерева в свежем клоне. - else - echo "::warning::ветка issue/${NUM}-* не найдена на origin — ревью пойдёт по dev" - echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY" - fi - - # Ревьюер обязан смотреть тот же код, который уедет в dev (#257). Раньше - # ревью шло по ветке как есть, а слияние делало ребейз — проверенный SHA и - # слитый SHA были разными коммитами. Пока расхождение с dev текстовое, - # ребейз упирается в конфликт и это видно; смысловое расхождение git - # склеивает молча, и в dev уезжает комбинация, которую никто не читал. - # Именно так пришёл регресс #234. - # - # Заодно снимается плата за конфликт: он обнаруживался ПОСЛЕ сорока минут - # ревью и потраченных лимитов подписки, хотя виден за пять секунд до них. - # - # Этап spec не затрагивается: ветку ТЗ в dev никто не сливает, и трогать - # чужую ветку без нужды — лишний риск. - - name: Привести ветку к dev - id: rebase - if: needs.guard.outputs.stage == 'code' && steps.branch.outputs.name != '' - env: - TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} - # #539: тем же токеном спрашивается REST — через него идёт и диспатч. - GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} - BRANCH: ${{ steps.branch.outputs.name }} - NUM: ${{ github.event.issue.number }} - # rebase, в отличие от commit, не принимает -c user.*: он запускает - # свои процессы и требует личность в окружении, иначе падает с - # «unable to auto-detect email address». - GIT_AUTHOR_NAME: claude[bot] - GIT_AUTHOR_EMAIL: 209825114+claude[bot]@users.noreply.github.com - GIT_COMMITTER_NAME: claude[bot] - GIT_COMMITTER_EMAIL: 209825114+claude[bot]@users.noreply.github.com - run: | - git fetch -q origin dev - if git merge-base --is-ancestor origin/dev HEAD; then - echo "ветка содержит весь dev — ребейз не нужен" - exit 0 - fi - behind=$(git rev-list --count "HEAD..origin/dev") - before=$(git rev-parse "origin/$BRANCH") - echo "dev впереди на $behind коммит(ов) — привожу ветку" - # #643: doc-коммит ветки конфликтует с документами других задач в dev - # только в генерируемом docs/reviews/INDEX.md — всегда, на каждом - # сдвиге dev. Помощник ребейза пересобирает индекс по каталогу, если - # ВСЕ конфликты остановки — индекс, и отказывает (с abort) на любом - # другом. Список конфликтов он снимает ДО abort (#364) и печатает в - # stdout по строке. Помощник берётся из dev, а не из ветки: ветка, - # отставшая от dev, его ещё не несёт. - tools="$RUNNER_TEMP/rebase-tools" - rm -rf "$tools" && mkdir -p "$tools" - git archive origin/dev scripts | tar -x -C "$tools" - code=0 - files=$(node "$tools/scripts/rebase-generated.mjs" --onto=origin/dev) || code=$? - # Код 3 — отказ с перечнем (ребейз отменён); любой другой ненулевой — - # сбой самого помощника, конфликтом ветки он не выдаётся. - if [ "$code" -ne 0 ] && [ "$code" -ne 3 ]; then - git rebase --abort 2>/dev/null || true - echo "::error::помощник ребейза упал (код $code) — это сбой конвейера, а не конфликт ветки" - exit 1 - fi - if [ "$code" -eq 3 ]; then - files=$(printf '%s\n' "$files" | sed '/^$/d' | sort -u | paste -sd'\n' -) - { - echo 'conflict=true' - echo 'conflicts<> "$GITHUB_OUTPUT" - echo "::warning::ветка $BRANCH не ребейзится на dev без конфликта — ревью не запускается" - printf 'конфликтуют:\n%s\n' "${files:-(git не назвал файлы)}" - exit 0 - fi - # #635 r2: ребейз мог принести в docs/reviews документы других задач, - # а закоммиченный INDEX.md — снимок каталога — их не знает. Свежесть - # держит тест «индекс свеж» в Validate, поэтому индекс пересобирается - # здесь же, коммитом конвейера (класс C), до фиксации материала. - node scripts/reviews-index.mjs --dir=docs/reviews --commit-if-stale --issue="$NUM" - # --force-with-lease с явным ожидаемым значением обязателен: между - # fetch и push автор мог запушить коммит, и слепой --force потерял бы - # его молча. Расхождение lease — падение прогона, а не предупреждение: - # ревью пошло бы по коду, которого на ветке уже нет. - if ! git push -q --force-with-lease="refs/heads/$BRANCH:$before" \ - "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \ - "HEAD:refs/heads/$BRANCH"; then - echo "::error::ветка $BRANCH изменилась во время ребейза — прогон прерван, чтобы не потерять коммит автора" - exit 1 - fi - # #539: ссылка на стороне GitHub доезжает не мгновенно, а гейт ниже - # просит `workflow_dispatch` ПО ИМЕНИ ВЕТКИ — SHA туда передать - # нельзя. 12.09 на #536 диспатч, отправленный через три секунды после - # этого push, встал на ДОпушевый SHA: гейт не нашёл прогона на - # материале и вернул задачу автору, которому чинить было нечего. - # Поэтому шаг не заканчивается, пока REST не отдаст новую вершину — - # именно REST, потому что через него же идёт и сам диспатч. - after=$(git rev-parse HEAD) - settled=false - for _ in $(seq 1 30); do - seen=$(gh api "repos/${{ github.repository }}/git/ref/heads/$BRANCH" \ - --jq .object.sha 2>/dev/null || true) - if [ "$seen" = "$after" ]; then settled=true; break; fi - sleep 2 - done - if [ "$settled" != "true" ]; then - echo "::error::ссылка $BRANCH за минуту не стала указывать на $after — диспатч встал бы на устаревший SHA" - exit 1 - fi - # Локальная ссылка обновляется тоже: шаг слияния берёт origin/$BRANCH, - # и без этого он ребейзил бы заново уже приведённое. - git fetch -q origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH" - short_before=$(git rev-parse --short "$before") - short_after=$(git rev-parse --short HEAD) - echo "note=Ветка приведена к dev конвейером до ревью: поверх легло $behind коммит(ов) dev, $short_before -> $short_after. После ребейза это другой код (§7.2) — разбор полный, а не по дельте." >> "$GITHUB_OUTPUT" - echo "ветка $BRANCH приведена к dev: $short_before -> $short_after" - - # Материал ревью — конкретный SHA (#312). Вердикт применим только к - # нему: если во время ревью в ветку прилетит коммит, шаг слияния обязан - # это заметить и отказаться, а не молча увезти в dev непроверенный код. - - name: Зафиксировать SHA материала ревью - id: material - if: steps.rebase.outputs.conflict != 'true' - env: - NUM: ${{ github.event.issue.number }} - REPO: ${{ github.repository }} - GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} - run: | - echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - # Якоря материала, устойчивые к ребейзу (#413, #414). SHA коммита - # ребейз меняет — содержимое нет: git адресует деревья и блобы их - # хешем. Снимаются здесь, ПОСЛЕ приведения к dev (#515): рабочая - # копия равна тому, что ревьюер прочтёт, и коммит с этим деревом - # уже запушен в ветку — следующий прогон найдёт его в свежем клоне. - # В шаге публикации дерево уже сброшено на целевую ветку, и - # спрашивать его поздно. - echo "tree=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT" - # ТЗ задачи: блоб переживает и ребейз, и удаление ветки, пока текст - # где-нибудь достижим. Файлов может не быть (инфраструктурная - # задача) или быть несколько (разбитое ТЗ) — тогда список пуст либо - # длиннее одного. - specs=$(git ls-files -s -- "docs/specs/${NUM}-*.md" \ - | awk '{print $2" "$4}' | tr '\n' ';') - echo "specs=$specs" >> "$GITHUB_OUTPUT" - # Тело issue — материал ревью ТЗ (#517): с переходом на ТЗ в теле это - # единственный якорь, доказывающий «вердикт вынесен на этом тексте». - # Читается здесь, а не из github.event.issue.body: между событием - # метки и вызовом модели проходят минуты (ребейз, гейт #510, ожидание - # Validate), и снимок события описывал бы не тот текст. - body=$(mktemp) - if gh issue view "$NUM" --repo "$REPO" --json body --jq .body > "$body"; then - digest=$(node -e ' - import("./scripts/review-doc-guard.mjs").then(async (m) => { - const { readFileSync } = await import("node:fs"); - process.stdout.write(m.issueBodyDigest(readFileSync(process.argv[1], "utf8"))); - }); - ' "$body") - echo "issue_body=$digest" >> "$GITHUB_OUTPUT" - echo "тело issue: ${digest:0:12}" - else - echo "::warning::тело issue $NUM не прочитано — якорь ТЗ в документ не попадёт" - fi - echo "материал ревью: $(git rev-parse --short HEAD), дерево $(git rev-parse --short 'HEAD^{tree}')" - - # Повторное применение зелёного вердикта без вызова модели (#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 }} - # Правка ТЗ между раундами обязана отменять повторное применение - # зелёного вердикта: иначе вызов модели пропускается и находку - # «ТЗ менялось» некому напечатать (#517 AC6). - ISSUE_BODY: ${{ steps.material.outputs.issue_body }} - run: | - out=$(node scripts/review-doc-guard.mjs --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD \ - --issue-body="${ISSUE_BODY}") - 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 — - # тоже смена метки, и автор не ждёт впустую. - - name: Конфликт с dev — вернуть автору без ревью - if: steps.rebase.outputs.conflict == 'true' - env: - GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} - NUM: ${{ github.event.issue.number }} - BRANCH: ${{ steps.branch.outputs.name }} - CONFLICTS: ${{ steps.rebase.outputs.conflicts }} - run: | - cat > /tmp/stale.md < S6-in-progress (ревью не запускалось)" - - # Мутанты по диффу бегут только по запросу (#510): до ревью конвейер - # запускает Validate с мутантами на материале и ждёт его. Красный или - # пропавший прогон возвращает задачу автору без ревью — цикл не - # тратится на код, который CI уже отверг (08.09: #437 дважды ушёл в S6 - # после запущенного 15-минутного ревью). Этап spec кода не несёт и - # гейт не проходит; повторное применение вердикта (#499) — тоже: там - # слияние само дожидается Validate на кандидате. - - name: Validate с мутантами на материале - id: gate - if: steps.rebase.outputs.conflict != 'true' - env: - GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} - STAGE: ${{ needs.guard.outputs.stage }} - REUSE: ${{ steps.reuse.outputs.reuse }} - BRANCH: ${{ steps.branch.outputs.name }} - SHA: ${{ steps.material.outputs.sha }} - run: | - if [ "$STAGE" != "code" ] || [ "$REUSE" = "true" ] || [ -z "$BRANCH" ]; then - echo "гейт не применяется: этап $STAGE, reuse=${REUSE:-false}, ветка ${BRANCH:-dev}" - { echo 'proceed=true'; echo 'result=skipped'; } >> "$GITHUB_OUTPUT" - exit 0 - fi - # #636: раннер не спит, пока идёт Validate (28 минут на раунд при - # 10–12 минутах работы модели). Гейт диспатчит прогон, убеждается, что - # тот встал на материал, и выходит с кодом 2 — «идёт». Раунд продолжит - # событие завершения Validate (process-resume.yml переставит метку - # S7), страховка — process-reconcile. Зелёный или красный завершённый - # прогон гейт и без ожидания возвращает сразу. - set +e - node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA" --no-wait - code=$? - set -e - case "$code" in - 0) echo 'proceed=true' >> "$GITHUB_OUTPUT" ;; - 2) echo 'proceed=pending' >> "$GITHUB_OUTPUT" ;; - *) echo 'proceed=false' >> "$GITHUB_OUTPUT" ;; - esac - - # #636: прогон на материале идёт — записать маркер ожидания и освободить - # раннер. Метка S7 остаётся; событие `workflow_run` по завершении Validate - # переставит её, и новый прогон конвейера найдёт завершённый dispatch - # сразу. Маркер читают process-resume.mjs и process-reconcile.mjs: без - # него ни один из них не имеет права будить раунд — иначе «успешный - # прогон без вердикта» неотличим от потерянного запроса. - - name: Validate идёт — раунд продолжит событие - id: pending - if: steps.rebase.outputs.conflict != 'true' && steps.gate.outputs.proceed == 'pending' - env: - NUM: ${{ github.event.issue.number }} - STAGE: ${{ needs.guard.outputs.stage }} - BRANCH: ${{ steps.branch.outputs.name }} - SHA: ${{ steps.material.outputs.sha }} - VALIDATE_RUN_ID: ${{ steps.gate.outputs.run_id }} - VALIDATE_URL: ${{ steps.gate.outputs.url }} - run: | - dir="$RUNNER_TEMP/review-pending" - mkdir -p "$dir" - jq -n -S \ - --arg run_id "$GITHUB_RUN_ID" --arg run_attempt "$GITHUB_RUN_ATTEMPT" \ - --arg issue "$NUM" --arg stage "$STAGE" --arg branch "$BRANCH" \ - --arg material_sha "$SHA" --arg validate_run_id "$VALIDATE_RUN_ID" \ - --arg validate_url "$VALIDATE_URL" \ - '{schema:1,run_id:$run_id,run_attempt:$run_attempt,issue:$issue,stage:$stage,branch:$branch,material_sha:$material_sha,validate_run_id:$validate_run_id,validate_url:$validate_url}' \ - > "$dir/pending.json" - (cd "$dir" && sha256sum pending.json > manifest.sha256) - echo "artifact=review-pending-${NUM}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" - short=$(git rev-parse --short "$SHA") - echo "Validate с мутантами на \`$short\` идёт — раннер освобождён, раунд продолжится по завершении прогона${VALIDATE_URL:+ ($VALIDATE_URL)}." >> "$GITHUB_STEP_SUMMARY" - - - name: Сохранить маркер ожидания - if: steps.gate.outputs.proceed == 'pending' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: ${{ steps.pending.outputs.artifact }} - path: ${{ runner.temp }}/review-pending - if-no-files-found: error - retention-days: 1 - - - name: Validate красный — вернуть автору без ревью - if: steps.rebase.outputs.conflict != 'true' && steps.gate.outputs.proceed == 'false' - env: - GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} - NUM: ${{ github.event.issue.number }} - BRANCH: ${{ steps.branch.outputs.name }} - SHA: ${{ steps.material.outputs.sha }} - RESULT: ${{ steps.gate.outputs.result }} - NOTE: ${{ steps.gate.outputs.note }} - URL: ${{ steps.gate.outputs.url }} - run: | - short=$(git rev-parse --short "$SHA") - cat > /tmp/gate.md < S6-in-progress (Validate с мутантами: $RESULT)" - - # Ревьюер перегонял tsc, юниты и сборку заново в каждом раунде, хотя - # Validate на том же SHA уже зелёный (#343). Это не тщательность: бюджет - # ревью тратится на повторение CI вместо чтения кода. - # - # Доказательство здесь такое же строгое, как у reuse-маркеров (#208): не - # «недавно было зелено», а «completed success ровно на этом SHA». После - # ребейза SHA другой, прогона для него нет — и ревьюер честно гоняет сам. - - name: Зелёные гейты на этом SHA - id: validated - if: steps.gate.outputs.proceed == 'true' - env: - GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} - run: | - sha=$(git rev-parse HEAD) - short=$(git rev-parse --short HEAD) - row=$(gh run list --repo "${{ github.repository }}" --workflow validate.yml \ - --commit "$sha" --limit 5 \ - --json status,conclusion,url \ - --jq '[.[] | select(.status=="completed" and .conclusion=="success")][0] // empty') - { - echo 'note<s+=d).on("end",()=>process.stdout.write(JSON.parse(s).url||""))') - echo "**Дешёвые гейты на этом SHA уже подтверждены** (#343). Validate на \`$short\` завершился success: $url" - echo "" - echo "Значит \`npx tsc --noEmit\`, \`npm test\` и \`npm run build\` со сверкой копий бандла перегонять не нужно — сошлись на этом прогоне, назвав его ссылкой. Бюджет раунда тратится на чтение кода." - echo "" - echo "Что Validate НЕ покрывает и остаётся за тобой: смоки, выбранные по диффу; golden, если diff трогает рендер; инварианты модели на конкретной конфигурации; и любой гейт, который требуют AC задачи." - else - echo "**Зелёного Validate на этом SHA (\`$short\`) нет** — прогон не найден, не завершён либо не success. Дешёвые гейты прогоняешь сам и называешь результат." - fi - echo 'EOF_NOTE' - } >> "$GITHUB_OUTPUT" - if [ -n "$row" ]; then echo "Validate на $short: зелёный"; else echo "Validate на $short: зелёного нет"; fi - - # Это deterministic evidence для промпта, поэтому вычисляется до запуска - # модели и передаётся вместе с неизменяемым контрактом материала (#551). - - name: "ТЗ менялось после зелёного ревью ТЗ (#517)" - id: spec_body - if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' && needs.guard.outputs.stage == 'code' - env: - NUM: ${{ github.event.issue.number }} - DIGEST: ${{ steps.material.outputs.issue_body }} - run: | - if [ -z "$DIGEST" ]; then - echo "хеша тела нет — сравнивать не с чем" - exit 0 - fi - out=$(node -e ' - import("./scripts/review-doc-guard.mjs").then(async (m) => { - const { execFileSync } = await import("node:child_process"); - const [num, digest] = process.argv.slice(1); - const git = (args) => { try { return execFileSync("git", args, { encoding: "utf8" }); } catch { return ""; } }; - const names = git(["ls-tree", "--name-only", "HEAD:docs/reviews"]).split("\n") - .filter((name) => new RegExp(`^SPEC-REVIEW-${num}-r\\d+\\.md$`).test(name)); - const docs = names.map((name) => ({ name, text: git(["show", `HEAD:docs/reviews/${name}`]) })); - const changed = m.issueBodyChanged(docs, digest); - if (changed) process.stdout.write(`changed=true\ndoc=${changed.doc}\nrecorded=${changed.recorded}\n`); - else process.stdout.write("changed=false\n"); - }); - ' "$NUM" "$DIGEST") - printf '%s\n' "$out" - printf '%s\n' "$out" >> "$GITHUB_OUTPUT" - - - name: Собрать контракт материала между стадиями - id: prepared - if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' - env: - NUM: ${{ github.event.issue.number }} - STAGE: ${{ needs.guard.outputs.stage }} - CYCLE: ${{ needs.guard.outputs.cycle }} - BRANCH: ${{ steps.branch.outputs.name }} - MATERIAL_SHA: ${{ steps.material.outputs.sha }} - MATERIAL_TREE: ${{ steps.material.outputs.tree }} - MATERIAL_SPECS: ${{ steps.material.outputs.specs }} - MATERIAL_ISSUE_BODY: ${{ steps.material.outputs.issue_body }} - VALIDATE_RESULT: ${{ steps.gate.outputs.result }} - VALIDATE_URL: ${{ steps.gate.outputs.url }} - REBASE_NOTE: ${{ steps.rebase.outputs.note }} - VALIDATED_NOTE: ${{ steps.validated.outputs.note }} - SPEC_BODY_CHANGED: ${{ steps.spec_body.outputs.changed }} - SPEC_BODY_DOC: ${{ steps.spec_body.outputs.doc }} - SPEC_BODY_RECORDED: ${{ steps.spec_body.outputs.recorded }} - run: | - dir="$RUNNER_TEMP/review-prepared" - mkdir -p "$dir" - jq -n -S \ - --arg run_id "$GITHUB_RUN_ID" --arg run_attempt "$GITHUB_RUN_ATTEMPT" \ - --arg issue "$NUM" --arg stage "$STAGE" --arg cycle "$CYCLE" \ - --arg branch "$BRANCH" --arg material_sha "$MATERIAL_SHA" \ - --arg material_tree "$MATERIAL_TREE" --arg material_specs "$MATERIAL_SPECS" \ - --arg material_issue_body "$MATERIAL_ISSUE_BODY" \ - --arg validate_result "$VALIDATE_RESULT" --arg validate_url "$VALIDATE_URL" \ - --arg rebase_note "$REBASE_NOTE" --arg validated_note "$VALIDATED_NOTE" \ - --arg spec_body_changed "$SPEC_BODY_CHANGED" --arg spec_body_doc "$SPEC_BODY_DOC" \ - --arg spec_body_recorded "$SPEC_BODY_RECORDED" \ - '{schema:1,run_id:$run_id,run_attempt:$run_attempt,issue:$issue,stage:$stage,cycle:$cycle,branch:$branch,material_sha:$material_sha,material_tree:$material_tree,material_specs:$material_specs,material_issue_body:$material_issue_body,validate_result:$validate_result,validate_url:$validate_url,rebase_note:$rebase_note,validated_note:$validated_note,spec_body_changed:$spec_body_changed,spec_body_doc:$spec_body_doc,spec_body_recorded:$spec_body_recorded}' \ - > "$dir/prepared.json" - (cd "$dir" && sha256sum prepared.json > manifest.sha256) - echo "artifact=review-prepared-${NUM}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" - - - name: Передать подтверждённый материал модели - if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: ${{ steps.prepared.outputs.artifact }} - path: ${{ runner.temp }}/review-prepared - if-no-files-found: error - retention-days: 1 - - - name: Зафиксировать длительность подготовки - id: duration - if: always() - env: - STARTED: ${{ steps.clock.outputs.started }} - run: | - seconds=$(( $(date +%s) - STARTED )) - echo "seconds=$seconds" >> "$GITHUB_OUTPUT" - echo "- deterministic prerequisites: **${seconds}s**" >> "$GITHUB_STEP_SUMMARY" - - model_review: - name: "Ревью: работа модели" - # Единственная недоверенная стадия: модель читает материал и кладёт - # запечатанный artifact. Писать в issue и в репозиторий ей незачем — это - # делает `integrate`, проверив происхождение: в репозиторий модель не пишет - # вообще, а в issue — только своим комментарием и отдельным issue по §12. - # - # Права здесь реальны только вместе с `github_token` у шага Review. Без него - # claude-code-action меняет OIDC на собственный App-токен с дефолтом - # `contents/issues/pull_requests: write` (`src/github/token.ts`), и блок ниже - # не ограничивает ничего: токен `ghs_…` от claude[bot] лежит прямо в - # окружении Bash-инструмента модели — это поймало ревью r1 по #556 в - # собственной же сессии. С переданным `secrets.GITHUB_TOKEN` обмена не - # происходит, `id-token` больше не нужен, и этот список становится потолком. - # - # `issues: write` остаётся: процесс требует от ревьюера комментарий с - # вердиктом (§7.2) и отдельный issue на Medium вне скоупа (§12). Снять его - # можно только перенеся и то и другое в `integrate` — это отдельная правка - # конвейера, не эта задача. Что остаётся модели этим правом: комментарий, - # метки, правка тела issue. Чего не остаётся: запись в репозиторий, слияние - # (его решает запечатанный verdict.json в `integrate`), релиз. - permissions: - contents: read - issues: write - needs: [guard, prepare] - if: needs.prepare.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' - runs-on: ubuntu-latest - concurrency: - group: process-issue-${{ github.event.issue.number }} - cancel-in-progress: false - # Весь бюджет принадлежит модели и её локальным проверкам; ожидания Validate - # в этом job больше нет (#551). - timeout-minutes: 45 - outputs: - duration_seconds: ${{ steps.duration.outputs.seconds }} - steps: - - name: Начать измерение стадии - id: clock - run: echo "started=$(date +%s)" >> "$GITHUB_OUTPUT" - - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - ref: ${{ needs.prepare.outputs.material_sha }} - persist-credentials: false - - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - with: - node-version: 22 - cache: npm - - - name: Получить контракт подготовленного материала - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: review-prepared-${{ github.event.issue.number }}-${{ github.run_id }}-${{ github.run_attempt }} - path: ${{ runner.temp }}/review-prepared - - - name: Проверить контракт и exact material - env: - NUM: ${{ github.event.issue.number }} - STAGE: ${{ needs.guard.outputs.stage }} - CYCLE: ${{ needs.guard.outputs.cycle }} - BRANCH: ${{ needs.prepare.outputs.branch }} - MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }} - MATERIAL_TREE: ${{ needs.prepare.outputs.material_tree }} - MATERIAL_SPECS: ${{ needs.prepare.outputs.material_specs }} - MATERIAL_ISSUE_BODY: ${{ needs.prepare.outputs.material_issue_body }} - VALIDATE_RESULT: ${{ needs.prepare.outputs.validate_result }} - VALIDATE_URL: ${{ needs.prepare.outputs.validate_url }} - REBASE_NOTE: ${{ needs.prepare.outputs.rebase_note }} - VALIDATED_NOTE: ${{ needs.prepare.outputs.validated_note }} - SPEC_BODY_CHANGED: ${{ needs.prepare.outputs.spec_body_changed }} - SPEC_BODY_DOC: ${{ needs.prepare.outputs.spec_body_doc }} - SPEC_BODY_RECORDED: ${{ needs.prepare.outputs.spec_body_recorded }} - run: | - dir="$RUNNER_TEMP/review-prepared" - (cd "$dir" && sha256sum -c manifest.sha256) - jq -e \ - --arg run_id "$GITHUB_RUN_ID" --arg run_attempt "$GITHUB_RUN_ATTEMPT" \ - --arg issue "$NUM" --arg stage "$STAGE" --arg cycle "$CYCLE" \ - --arg branch "$BRANCH" --arg sha "$MATERIAL_SHA" --arg tree "$MATERIAL_TREE" \ - --arg specs "$MATERIAL_SPECS" --arg body "$MATERIAL_ISSUE_BODY" \ - --arg validate "$VALIDATE_RESULT" --arg validate_url "$VALIDATE_URL" \ - --arg rebase_note "$REBASE_NOTE" --arg validated_note "$VALIDATED_NOTE" \ - --arg spec_changed "$SPEC_BODY_CHANGED" --arg spec_doc "$SPEC_BODY_DOC" \ - --arg spec_recorded "$SPEC_BODY_RECORDED" \ - '.schema == 1 and .run_id == $run_id and .run_attempt == $run_attempt and .issue == $issue and .stage == $stage and .cycle == $cycle and .branch == $branch and .material_sha == $sha and .material_tree == $tree and .material_specs == $specs and .material_issue_body == $body and .validate_result == $validate and .validate_url == $validate_url and .rebase_note == $rebase_note and .validated_note == $validated_note and .spec_body_changed == $spec_changed and .spec_body_doc == $spec_doc and .spec_body_recorded == $spec_recorded' \ - "$dir/prepared.json" - test "$(git rev-parse HEAD)" = "$MATERIAL_SHA" - test "$(git rev-parse 'HEAD^{tree}')" = "$MATERIAL_TREE" - - # Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог - # измениться именно в ней, и установка по копии из dev дала бы не то дерево. - - name: Установить зависимости - run: npm ci - - # Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте), - # но когда нужен — качать его заново дороже, чем держать в кэше. - - name: Кэш браузеров Playwright - id: pw - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} - - - name: Установить Chromium - if: steps.pw.outputs.cache-hit != 'true' - # Без --with-deps: системные библиотеки Chromium предустановлены в - # образе ubuntu-latest, а apt при промахе кэша съедал минуты из бюджета - # ревью и подолгу перебирал недоступное azure-зеркало (#175). Если - # библиотека когда-нибудь пропадёт из образа, Chromium не запустится с - # внятной ошибкой — тогда флаг вернуть. - 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 - 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 - uses: anthropics/claude-code-action@9cdae7f0d995e3ba7c33f226087fdf82a59cd520 # v1 - env: - # Вне рабочей копии: восстановление дерева ревьюером не должно - # уничтожать его собственный артефакт (#220). - REVIEW_DOC: ${{ runner.temp }}/review-document.md - with: - # Подписка, а не отдельный счёт API: токен выпускается через - # `claude setup-token` (Pro/Max). Действуют лимиты подписки. - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - # Ambient job-scoped токен вместо App-обмена (#556). Со ним - # `permissions:` этой job — настоящий потолок прав модели: ни записи в - # репозиторий, ни постановки метки, ни комментария. Строку нельзя - # снять, не вернув модели право двигать процесс. - github_token: ${{ secrets.GITHUB_TOKEN }} - path_to_claude_code_executable: ${{ steps.claude_bin.outputs.path }} - prompt: | - Ты ревьюер проекта House Plan. Язык ответа — русский. - - Issue: #${{ github.event.issue.number }} - Репозиторий: ${{ github.repository }} - Этап: ${{ needs.guard.outputs.stage }} - spec — ревью ТЗ (PROCESS.md §2.4) - code — код-ревью (PROCESS.md §2.7) - Заход: r${{ needs.guard.outputs.cycle }} · блокирующих циклов израсходовано ${{ needs.guard.outputs.spent }} из ${{ needs.guard.outputs.limit }} - Бюджет §4 тратят только жёлтые и красные вердикты: зелёный - ничего не вернул на правки и цикла не образует (#227). - Номер захода нужен для имени документа — два документа с - одинаковым номером затёрли бы друг друга. - - ${{ needs.prepare.outputs.rebase_note }} - - ${{ needs.prepare.outputs.spec_body_changed == 'true' && format('ТЗ в теле issue менялось после зелёного ревью ТЗ ({0}, записанный хеш {1}). GitHub хранит правки тела без diff — дельту показать нельзя, поэтому AC сверяются с ТЕКУЩИМ текстом целиком, а не по дельте, и находка называется в вердикте (#517).', needs.prepare.outputs.spec_body_doc, needs.prepare.outputs.spec_body_recorded) || '' }} - - Правила ревью в этом промпте не повторяются (#634): их канон — - PROCESS.md, выжимка для ревьюера — docs/process/REVIEWER.md, где - каждый пункт ссылается на свой раздел канона. Ниже — только то, что - относится к этому прогону, и требования, которые нельзя пропустить. - - Прочитай в этом порядке, прежде чем судить: - 1. docs/SCOPE.md — зачем продукт существует и для кого. Он - ограничитель: «features are built, improved and accepted only - if they serve a job listed here». Первый вопрос к задаче — - какую строку Core user jobs она закрывает. - 2. docs/process/REVIEWER.md — обязанности ревьюера: позиция, - ревью ТЗ, код-ревью, объём гейтов, повторный раунд, находки и - вердикт. Раздел PROCESS.md по ссылке открывай, когда пункт - касается твоего решения; при расхождении прав PROCESS.md. Если - файла в материале нет — читай PROCESS.md §2.4, §2.7, §2.10, - §4, §7.2, §8, §12. Задача правит сам конвейер, гейты или - процесс — PROCESS.md целиком, §10 в первую очередь. - 3. AGENTS.md — классы изменений, трейлеры, гейты, формат вердикта. - 4. Тело issue #${{ github.event.issue.number }} и все комментарии. - 5. Если меняется видимое поведение — docs/USER-GUIDE.ru.md: - терминология интерфейса берётся оттуда, а не изобретается. - 6. Канонический документ затронутой подсистемы: docs/SUN.md, - LIGHT.md, CANVAS.md, WALL-THICKNESS.md, UX-MODES.md, - CONFIG-COMPATIBILITY.md, TOUCH-SUPPORT.md. - - **Если цикл не первый — объём разбора по дельте, а не заново** - (PROCESS.md §2.10, issue #214): найди вердикт и материал - предыдущего раунда (блок «Материал раунда» его документа), объяви - дельту `git diff <тот SHA>..HEAD` (для spec — дифф тела issue или - файла ТЗ), по каждой находке покажи, чем именно она закрыта — - строка кода или текста, а не заявление автора, — и заново проверяй - только AC, чьё доказательство дельта задевает. Разбор остаётся - ПОЛНЫМ, если дельта не локальна: ребейз на ушедший вперёд dev, - смена контракта, новая подсистема, объём сопоставим с задачей. - Сомневаешься — разбирай полностью и скажи почему. Сокращается - объём РАЗБОРА, а не строгость. - - Для этапа spec: ТЗ живёт в теле issue (#517) — читай его, а не файл. - Файлы docs/specs/-*.md — архив ТЗ до 2026-09-10: если такой файл - есть у старой задачи, он и есть материал, новые не создаются. - Проверь обязательные разделы §7.1, однозначность каждого AC и - указание способа доказательства. Утверждение о поведении, которого - нет ни в одном документе и которое не помечено как предположение, — - замечание. Не бывает сложной задачи без единого открытого вопроса. - Технический вопрос, вынесенный владельцу, — тоже замечание: ты его - снимаешь и решаешь по существу в своём вердикте. - - Для этапа code: материал — диапазон `git log --oneline origin/dev..HEAD` - и `git diff origin/dev...HEAD`. **Материал ревью — ровно - `${{ needs.prepare.outputs.material_sha }}`, рабочая копия уже на нём.** Не - делай `git fetch`, `git pull` и `git checkout` на другой коммит: вердикт - привязан к этому SHA (#312), и страж слияния сверяет вершину ветки с - ним. Если автор в issue называет более новый коммит, которого в - материале нет, — это находка «материал не был запушен до метки», а не - повод подтянуть его самому (#499). Ручного тестирования в цикле нет, - поэтому именно ты отвечаешь на вопрос «оно вообще работает». - По каждому AC: либо он доказан автотестом и ты убедился, что тест - умеет падать, либо разобран по коду с явной записью «проверено - чтением, не исполнением». Для защитного AC (валидация, гард, лимит, - отказ, инвариант) в документе обязательна строка таблицы - «AC · чем доказан · чем краснеет» с результатом прогона; пустой - третий столбец — находка Medium (§2.7, #435). «Verified» без - названной команды и её результата доказательством не является. - Проверь трейлеры Issue и User-Visible, при User-Visible: yes — правки - в оба changelog в том же коммите. Если дифф меняет величину, видимую - пользователю, назови прямо: какое число видно дважды и один ли у - него источник (§8). - - **Объём гейтов соразмерен задаче** (PROCESS.md §8): полные наборы — - предрелизный гейт, а не гейт ревью. - - ${{ needs.prepare.outputs.validated_note }} - - Если зелёного прогона на этом SHA нет — прогоняешь сам, они дешёвые, - и в повторном раунде тоже: `npx tsc --noEmit`, `npm test`, - `npm run build` со сверкой трёх копий бандла, плюс - `node scripts/check-docs.mjs`, если diff трогает `src/**`. - Зависимости уже установлены workflow, Chromium тоже — `npm ci` - выполнять не нужно. По диффу и AC: браузерные смоки — названные в AC - плюс вывод `node scripts/smoke-select.mjs --base --head `, - приложенный к комментарию с решением по каждой строке: прогнал либо - не прогнал и почему. Три вида ответа инструмента разные: «прямое - совпадение», «зарегистрированная связь», «НЕОПРЕДЕЛЁННОСТЬ» — связь - не доказана, и это не разрешение ничего не прогонять; слабые связи — - повод посмотреть, а не обязанность прогонять; - `npm run golden:verify` при видимом изменении; - `python -m pytest tests_backend -q` при правке - `custom_components/**/*.py`; инварианты модели - `npm run invariants -- --config <экспорт>` при правке геометрии или - ссылок на неё (#254) — задача меняет геометрию, а инварианты в - отчёте не названы, это непрогнанный гейт, а не мелочь; - performance-профили, если названы в AC. Дисциплина «тест должен - уметь падать» не отменяется, но применяется к тем тестам, которые - ты прогонял. - - **В комментарии обязателен перечень: какие гейты прогнал, какие нет и - почему.** Раздел «чего не проверял» в документе ревью — не - формальность, а главный его раздел на коротких задачах. - - Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь. - - Серьёзность: High блокирует; Medium В СКОУПЕ задачи чинится в ней - же — без High это жёлтый вердикт и возврат автору, отдельный issue - НЕ заводится (#202); Low либо правится, либо снимается с записью. - Жёлтый вердикт допустим и при полностью выполненных AC, если - изменение не решает заявленный сценарий или ухудшает смежный. - Продуктовое рассуждение расширяет вопросы, но не отменяет AC и не - даёт права менять скоуп. - - Только Medium-находку ВНЕ скоупа задачи (попутный дефект соседнего - поведения, который в этой ветке чинить нельзя) заведи отдельным - issue со ссылкой на #${{ github.event.issue.number }} и метками: - тип, приоритет, S1-new. «Оставили в тексте ревью» закрытием не - считается и прямо запрещено §12. - - Напиши полный документ ревью в файл, путь которого лежит в - переменной окружения REVIEW_DOC (абсолютный, ВНЕ репозитория). - Не в docs/reviews: восстановление дерева после проверки «умеет ли - тест падать» (`git checkout -- .`, `git clean -fd`) сносит - untracked-файл, и на #220 документ так исчезал три раунда подряд. - В репозиторий его положит шаг публикации, взяв из REVIEW_DOC; - тебе трогать docs/reviews не нужно. - - В самом репозитории не создавай файлов вообще: любые изменения в - рабочей копии будут отброшены. Имя документа в docs/reviews шаг - публикации соберёт сам — SPEC-REVIEW для этапа spec, CODE-REVIEW для - code, с номером issue и заходом. - - Содержание документа: скоуп, как проверялось, находки с - воспроизведением, что проверено и корректно, чего не проверял. Для - r2 и дальше добавь два раздела: «Закрытие раунда r» — таблица - «находка | чем закрыта | где это видно», и «Унаследовано из r» — - что принято без повторной проверки, с документом и SHA. - - Затем оставь в issue краткий комментарий: вердикт, ключевые находки - и ссылка на документ. Первой строкой — вердикт в формате §7.2: - `Вердикт: зелёный/жёлтый/красный · заход r${{ needs.guard.outputs.cycle }} · блокирующих циклов ${{ needs.guard.outputs.spent }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → в задаче | #…` - («→ #…» — только у Medium вне скоупа; находки в скоупе возвращаются автору жёлтым) - - Затем верни JSON по схеме. Это последнее действие и оно обязательно: - без него метка не переставится и конвейер встанет. - claude_args: | - --max-turns 150 - --allowedTools Read,Write,Grep,Glob,Bash,mcp__github__add_issue_comment,mcp__github__issue_write,mcp__github__issue_read - --json-schema '{"type":"object","properties":{"verdict":{"type":"string","enum":["green","yellow","red"]},"high":{"type":"integer"},"medium":{"type":"integer"},"summary":{"type":"string"}},"required":["verdict","high","medium","summary"]}' - - - name: Запечатать результат модели - id: result - env: - SOURCE: ${{ runner.temp }}/review-document.md - OUT: ${{ steps.review.outputs.structured_output }} - NUM: ${{ github.event.issue.number }} - STAGE: ${{ needs.guard.outputs.stage }} - CYCLE: ${{ needs.guard.outputs.cycle }} - run: | - marker=CODE-REVIEW - if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi - legacy="docs/reviews/${marker}-${NUM}-r${CYCLE}.md" - if [ ! -f "$SOURCE" ] && [ -f "$legacy" ]; then cp "$legacy" "$SOURCE"; fi - test -s "$SOURCE" || { echo "::error::модель не оставила документ ревью"; exit 1; } - printf '%s' "$OUT" > "$RUNNER_TEMP/verdict.json" - jq -e ' - (.verdict == "green" or .verdict == "yellow" or .verdict == "red") - and (.high | type == "number") and (.medium | type == "number") - and (.summary | type == "string")' "$RUNNER_TEMP/verdict.json" >/dev/null - dir="$RUNNER_TEMP/review-result" - mkdir -p "$dir" - cp "$RUNNER_TEMP/review-prepared/prepared.json" "$dir/prepared.json" - cp "$SOURCE" "$dir/review-document.md" - cp "$RUNNER_TEMP/verdict.json" "$dir/verdict.json" - (cd "$dir" && sha256sum prepared.json review-document.md verdict.json > manifest.sha256) - echo "artifact=review-result-${NUM}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" - - - name: Передать результат интеграции - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: ${{ steps.result.outputs.artifact }} - path: ${{ runner.temp }}/review-result - if-no-files-found: error - retention-days: 1 - - - name: Зафиксировать длительность модели - id: duration - if: always() - env: - STARTED: ${{ steps.clock.outputs.started }} - run: | - seconds=$(( $(date +%s) - STARTED )) - echo "seconds=$seconds" >> "$GITHUB_OUTPUT" - echo "- model review: **${seconds}s**" >> "$GITHUB_STEP_SUMMARY" - - integrate: - name: "Ревью: публикация и интеграция" - # Публикует разбор, переставляет метку, сливает проверенный материал. - permissions: - contents: read - issues: write - needs: [guard, prepare, model_review] - if: always() && needs.guard.outputs.stage != '' - runs-on: ubuntu-latest - concurrency: - group: process-issue-${{ github.event.issue.number }} - cancel-in-progress: false - # Слияние кандидата при ушедшем dev может само ждать Validate до 45 минут; - # оно не должно обрывать уже оплаченный model review (#551). - timeout-minutes: 55 - steps: - - name: Начать измерение стадии - id: clock - run: echo "started=$(date +%s)" >> "$GITHUB_OUTPUT" - - - name: Проверить исходы предыдущих стадий - id: ready - env: - PREPARE_RESULT: ${{ needs.prepare.result }} - MODEL_RESULT: ${{ needs.model_review.result }} - PROCEED: ${{ needs.prepare.outputs.proceed }} - REUSE: ${{ needs.prepare.outputs.reuse }} - run: | - if [ "$PREPARE_RESULT" != "success" ]; then - echo "::error::стадия deterministic prerequisites завершилась: $PREPARE_RESULT" - exit 1 - fi - if [ "$PROCEED" = "pending" ]; then - echo "Validate на материале ещё идёт — раунд продолжит событие завершения (#636); интегрировать нечего" - echo "proceed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - if [ "$PROCEED" != "true" ]; then - echo "подготовка уже вернула задачу автору; интегрировать нечего" - echo "proceed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - if [ "$REUSE" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then - echo "::error::стадия model review завершилась: $MODEL_RESULT" - exit 1 - fi - echo "proceed=true" >> "$GITHUB_OUTPUT" - - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - if: steps.ready.outputs.proceed == 'true' - with: - fetch-depth: 0 - ref: dev - persist-credentials: false - - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 - if: steps.ready.outputs.proceed == 'true' - with: - node-version: 22 - - - name: Получить результат модели - if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: review-result-${{ github.event.issue.number }}-${{ github.run_id }}-${{ github.run_attempt }} - path: ${{ runner.temp }}/review-result - - - name: Проверить полноту и происхождение результата - id: result - if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' - # #556: единственное, что недоверенная стадия модели может передать - # дальше, — этот artifact, и принимается он как ввод противника: полный - # набор файлов, сходящиеся суммы, совпадение КАЖДОГО поля паспорта с - # тем, что посчитала детерминированная `prepare`. Проверка вынесена из - # inline-shell в `scripts/review-result-gate.mjs` ради враждебных - # фикстур — в YAML её нельзя прогнать ни одним отрицательным случаем. - env: - ISSUE: ${{ github.event.issue.number }} - STAGE: ${{ needs.guard.outputs.stage }} - CYCLE: ${{ needs.guard.outputs.cycle }} - BRANCH: ${{ needs.prepare.outputs.branch }} - MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }} - MATERIAL_TREE: ${{ needs.prepare.outputs.material_tree }} - MATERIAL_SPECS: ${{ needs.prepare.outputs.material_specs }} - MATERIAL_ISSUE_BODY: ${{ needs.prepare.outputs.material_issue_body }} - VALIDATE_RESULT: ${{ needs.prepare.outputs.validate_result }} - VALIDATE_URL: ${{ needs.prepare.outputs.validate_url }} - REBASE_NOTE: ${{ needs.prepare.outputs.rebase_note }} - VALIDATED_NOTE: ${{ needs.prepare.outputs.validated_note }} - SPEC_BODY_CHANGED: ${{ needs.prepare.outputs.spec_body_changed }} - SPEC_BODY_DOC: ${{ needs.prepare.outputs.spec_body_doc }} - SPEC_BODY_RECORDED: ${{ needs.prepare.outputs.spec_body_recorded }} - run: | - set -euo pipefail - dir="$RUNNER_TEMP/review-result" - node scripts/review-result-gate.mjs --dir="$dir" - printf 'structured_output<> "$GITHUB_OUTPUT" - cat "$dir/verdict.json" >> "$GITHUB_OUTPUT" - # structured_output не обязан оканчиваться LF: delimiter команды - # GitHub должен начинаться с отдельной строки. - printf '\nEOF_RESULT\n' >> "$GITHUB_OUTPUT" - - # Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит, - # решает этот шаг, а не модель: всё остальное откатывается. - - name: Опубликовать документ ревью - if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' - env: - TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} - BRANCH: ${{ needs.prepare.outputs.branch }} - NUM: ${{ github.event.issue.number }} - STAGE: ${{ needs.guard.outputs.stage }} - CYCLE: ${{ needs.guard.outputs.cycle }} - SOURCE: ${{ runner.temp }}/review-result/review-document.md - # После ребейза конвейером — якоря приведённого материала (#515). - MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }} - MATERIAL_TREE: ${{ needs.prepare.outputs.material_tree }} - MATERIAL_SPECS: ${{ needs.prepare.outputs.material_specs }} - MATERIAL_ISSUE_BODY: ${{ needs.prepare.outputs.material_issue_body }} - # Вердикт из structured_output попадает в блок якорей (#499): по нему - # следующий заход решает, можно ли применить зелёный вердикт повторно. - OUT: ${{ steps.result.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. Раньше шаг в этом случае молча - # выходил с нулём, и разбор ревью терялся — оставался только вердикт - # комментарием. Это тот же тихий отказ: шаг сообщал об успехе тем, что - # ничего не сделал. Документ ложится туда же, где лежит само ТЗ. - target="${BRANCH:-dev}" - if [ -z "$BRANCH" ]; then - echo "::warning::ветки задачи нет — документ ревью ляжет в dev" - fi - marker=CODE-REVIEW - if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi - doc="docs/reviews/${marker}-${NUM}-r${CYCLE}.md" - # Документ спасается ПЕРВЫМ делом. Ревьюер мог написать его по старому - # пути прямо в рабочую копию, а дальше эта копия будет отброшена - # целиком — и вместе с ней пропал бы артефакт (#220). - if [ ! -f "$SOURCE" ] && [ -f "$doc" ]; then - cp "$doc" "$SOURCE" - echo "документ найден в рабочей копии и сохранён в $SOURCE" - fi - # Reset, а не checkout+clean, и вот почему (#365). - # - # 28.08 коммит bb2919f уехал в dev с тридцатью файлами вместо одного - # markdown: откатил отревьюженную реализацию #359, вернул старые чанки - # и держал dev откаченным три часа. Механизм воспроизведён: - # `git checkout -- .` восстанавливает рабочее дерево ИЗ ИНДЕКСА, а - # `git clean -fd` убирает неотслеживаемое — ни то, ни другое индекс не - # трогает. Ревьюер работает с Bash и в ходе проверки «умеет ли тест - # падать» вполне может сделать `git add`; всё, что осталось у него в - # индексе, прежняя уборка сохраняла, и следующий же `git commit` - # забирал это вместе с документом. Сообщение при этом невинное, и от - # рутины инцидент отличается только диффом. - # - # `reset --hard` снимает и индекс, и дерево разом. Терять нечего: - # документ приезжает извне репозитория, из RUNNER_TEMP. - git fetch -q origin "$target" - git reset -q --hard "origin/$target" - git clean -fdq -e node_modules >/dev/null 2>&1 || true - # Документ приезжает извне репозитория (#220). Три раунда подряд он - # терялся, пока лежал некоммитнутым файлом в том же дереве, которое - # ревьюер мутирует и затем восстанавливает: `git checkout -- .` плюс - # `git clean -fd` сносят собственный артефакт ревью, потому что он - # untracked. Теперь его место — RUNNER_TEMP, и уборка дерева ему не - # страшна. - if [ -f "$SOURCE" ]; then - mkdir -p docs/reviews - cp "$SOURCE" "$doc" - echo "документ взят из $SOURCE ($(wc -c < "$doc") байт)" - # Якоря дописывает конвейер, а не ревьюер (#414). Дисциплина здесь - # уже подводила: на #403 SHA сняли до ребейза и не сверили перед - # выводом — через раунд команда из §2.10 не работала. Машина же - # снимает якоря в момент чтения материала и ошибиться в них не - # может; блок помечен как машинный, чтобы никто не правил его руками. - node scripts/review-doc-guard.mjs --anchor="$doc" \ - --sha="$MATERIAL_SHA" --tree="$MATERIAL_TREE" \ - --branch="${BRANCH:-dev}" --specs="$MATERIAL_SPECS" \ - --issue-body="$MATERIAL_ISSUE_BODY" \ - --verdict="$verdict" --high="$high" - else - echo "::warning::$SOURCE не найден — документа для публикации нет" - fi - # Индексируется ровно один путь, а не каталог: `git add docs/reviews` - # забрал бы всё, что там окажется, а после reset там не должно быть - # ничего постороннего — но полагаться на «не должно» здесь нельзя. - git add -- "$doc" 2>/dev/null || true - # #635: индекс ревью пересобирается тем же коммитом, что и документ — - # иначе он устаревает на первом же раунде. Генерируемый файл, класс C. - if [ -f "$doc" ]; then - node scripts/reviews-index.mjs --dir=docs/reviews - git add -- docs/reviews/INDEX.md - fi - if git diff --cached --quiet; then - # Пустая рабочая копия — ещё не провал: ревьюер иногда коммитит - # документ сам, своим app-токеном мимо этого шага (CODE-REVIEW-150-r1, - # коммиттер GitHub). Провал — когда файла нет и на ветке. - git fetch -q origin "$target" - if git cat-file -e "origin/$target:$doc" 2>/dev/null; then - echo "документ уже опубликован ревьюером: $doc" - exit 0 - fi - # Ревью без артефакта запрещено (PROCESS.md §2.4/§10.4/§12). Раньше - # здесь стоял warning с exit 0: на #150 оба вердикта ревью ТЗ - # остались только комментариями, метки переставились, и пропажу - # заметило лишь следующее ревью — issue #171. Падение ДО шага с - # меткой сохраняет инвариант «метка не сменилась = прогон упал». - echo "::error::вердикт есть, а документа нет: ни $SOURCE, ни $doc в рабочей копии, ни $doc в $target — ревью без артефакта (#171, #220)" - exit 1 - fi - # Первый рубеж: что вообще проиндексировано. - git diff --cached --name-only | node scripts/review-doc-guard.mjs - git -c user.name="claude[bot]" \ - -c user.email="209825114+claude[bot]@users.noreply.github.com" \ - commit -q -F - </dev/null; then - echo "::error::коммит в $target опубликован, но ожидаемого $doc в нём нет — файл назван не по формату (#171)" - exit 1 - fi - echo "документ опубликован в $target: $doc" - - # Материал раунда обязан быть достижим с origin (#413). - # - # SPEC-REVIEW-403-r2 объявил материал на `HEAD = 83005c3c`, и тот же SHA - # независимо назвал автор ТЗ в комментарии issue. Коммит существовал, но - # к моменту публикации был осиротевшим: ветку перебазировали за 15 минут - # ДО публикации документа, спец-коммит переехал в 94502d3d с тем же - # сообщением и тем же содержимым. Через раунд команда `git diff - # 83005c3c..HEAD` из §2.10 буквально не работала, и r3 восстанавливал - # реальный коммит по содержимому диффа руками. - # - # Проверка стоит ПОСЛЕ публикации намеренно. Артефакт ревью терялся здесь - # трижды (#171, #220), и «вердикт без документа» в этом репозитории - # дороже мёртвой ссылки: документ сначала спасается, потом судится. Шаг - # при этом идёт ДО «Переставить метку», поэтому инвариант «метка не - # сменилась = прогон упал» сохраняется. - # - # Достижимость считается от `refs/remotes/origin/*`, а не от локальных - # ссылок: осиротевший 83005c3c до сих пор лежит в клоне автора и - # достижим там из необновлённой локальной ветки. Читателю отчёта от этого - # пользы нет — он достанет только то, что есть на origin. - - name: "Материал раунда воспроизводим (#413)" - if: steps.ready.outputs.proceed == 'true' && needs.prepare.outputs.reuse != 'true' - env: - NUM: ${{ github.event.issue.number }} - STAGE: ${{ needs.guard.outputs.stage }} - CYCLE: ${{ needs.guard.outputs.cycle }} - BRANCH: ${{ needs.prepare.outputs.branch }} - run: | - marker=CODE-REVIEW - if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi - doc="docs/reviews/${marker}-${NUM}-r${CYCLE}.md" - target="${BRANCH:-dev}" - git fetch -q origin "$target" - # Судится опубликованная версия, а не рабочая копия: именно её прочтёт - # следующий раунд. - git show "origin/$target:$doc" | node scripts/review-doc-guard.mjs --doc=- - - name: Решение по вердикту - id: decide - if: steps.ready.outputs.proceed == 'true' - env: - OUT: ${{ steps.result.outputs.structured_output }} - STAGE: ${{ needs.guard.outputs.stage }} - REUSE: ${{ needs.prepare.outputs.reuse }} - REUSE_DOC: ${{ needs.prepare.outputs.reuse_doc }} - REUSE_ROUND: ${{ needs.prepare.outputs.reuse_round }} - REUSE_TREE: ${{ needs.prepare.outputs.reuse_tree }} - GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} - NUM: ${{ github.event.issue.number }} - run: | - 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" - - # Вперёд двигает ТОЛЬКО зелёный. Жёлтый и красный возвращают - # автору: на прогоне #111 жёлтый означал, что AC описывает неверное - # изменение контракта — реализовать такое ТЗ значит сделать ошибку - # по инструкции. Оба считаются циклом. - if [ "$verdict" = "green" ] && [ "$high" -eq 0 ]; then - green=true - case "$STAGE" in - spec) from=S4-spec-review; to=S5-ready ;; - code) from=S7-code-review; to=S8-merged ;; - esac - else - green=false - case "$STAGE" in - spec) from=S4-spec-review; to=S3-spec ;; - code) from=S7-code-review; to=S6-in-progress ;; - esac - fi - echo "green=$green" >> "$GITHUB_OUTPUT" - echo "from=$from" >> "$GITHUB_OUTPUT" - echo "to=$to" >> "$GITHUB_OUTPUT" - - # Ревью идёт десятки минут, а dev за это время двигается (28 августа — - # четыре раза за день). Вердикт при этом вынесен по дереву, которое уже не - # совпадает с вершиной линии, и слияние приведёт ветку к dev — то есть в - # dev уедет код, отличный от прочитанного (§7.2). Молчать об этом нельзя, - # но и шуметь на каждом прогоне ни к чему: строка появляется только когда - # dev действительно ушёл и вердикт зелёный, то есть слияние вот-вот - # случится (#364). - - name: dev ушёл вперёд, пока шло ревью - if: steps.ready.outputs.proceed == 'true' && needs.guard.outputs.stage == 'code' - env: - GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} - NUM: ${{ github.event.issue.number }} - MATERIAL: ${{ needs.prepare.outputs.material_sha }} - GREEN: ${{ steps.decide.outputs.green }} - run: | - git fetch -q origin dev - moved=$(git rev-list --count "$MATERIAL..origin/dev") - echo "dev продвинулся на $moved коммит(ов) с момента фиксации материала" - echo "- dev продвинулся на **$moved** коммит(ов) во время ревью" >> "$GITHUB_STEP_SUMMARY" - if [ "$moved" -eq 0 ] || [ "$GREEN" != "true" ]; then exit 0; fi - short=$(git rev-parse --short "$MATERIAL") - gh issue comment "$NUM" --repo "${{ github.repository }}" --body \ - "Пока шло ревью, \`dev\` продвинулся на $moved коммит(ов). Материал ревью — \`$short\`. Вердикт вынесен по дереву, которое уже не совпадает с вершиной линии: слияние приведёт ветку к dev, и это другой код (§7.2)." - - # 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: - HP_PROCESS_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} - GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} - BRANCH: ${{ needs.prepare.outputs.branch }} - NUM: ${{ github.event.issue.number }} - MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }} - run: | - if [ -z "$BRANCH" ]; then - echo "::error::ветки задачи нет — сливать нечего" - echo "merged=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - node scripts/merge-candidate.mjs --branch="$BRANCH" --material="$MATERIAL_SHA" \ - --issue="$NUM" --repo="${{ github.repository }}" - - - name: Переставить метку - if: steps.ready.outputs.proceed == 'true' - env: - # Именно PAT: с GITHUB_TOKEN следующий шаг конвейера не запустится. - GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} - NUM: ${{ github.event.issue.number }} - FROM: ${{ steps.decide.outputs.from }} - # Зелёное код-ревью без слияния ведёт не в S8-merged, а обратно к - # автору: метка утверждала бы, что код в dev, а его там нет. - # Точный кандидат (#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" - echo "$FROM -> $TO" - - - name: Сводка длительности стадий - if: always() - env: - STARTED: ${{ steps.clock.outputs.started }} - PREPARE_SECONDS: ${{ needs.prepare.outputs.duration_seconds }} - MODEL_SECONDS: ${{ needs.model_review.outputs.duration_seconds }} - run: | - integration=$(( $(date +%s) - STARTED )) - { - echo "## Бюджеты стадий (#551)" - echo "" - echo "| Стадия | Длительность | Лимит |" - echo "|---|---:|---:|" - echo "| deterministic prerequisites | ${PREPARE_SECONDS:-нет полного измерения}s | 55 min |" - echo "| model review | ${MODEL_SECONDS:-не запускалась}s | 45 min |" - echo "| publication/integration | ${integration}s | 55 min |" - } >> "$GITHUB_STEP_SUMMARY" - - - name: Позвать владельца, если стадия упала - if: failure() - env: - GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - PREPARE_RESULT: ${{ needs.prepare.result }} - MODEL_RESULT: ${{ needs.model_review.result }} - PROCEED: ${{ needs.prepare.outputs.proceed }} - REUSE: ${{ needs.prepare.outputs.reuse }} - run: | - stage="публикация/интеграция" - detail="Модель уже завершила работу; её запечатанный результат сохранён artifact-ом этого run, но не был применён." - if [ "$PREPARE_RESULT" != "success" ]; then - stage="deterministic prerequisites" - detail="Модель не запускалась, цикл ревью не израсходован." - elif [ "$PROCEED" = "true" ] && [ "$REUSE" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then - stage="model review" - detail="Полного валидного результата модели нет; метка не менялась." - fi - # Тело через heredoc, а не многострочный --body: строка с нулевым - # отступом обрывает блок YAML и оставляет незакрытую кавычку. - cat > /tmp/failure.md <`: по блобу на файл. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: { fetch-depth: 0, filter: 'blob:none' } - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 @@ -106,26 +106,32 @@ jobs: continue-on-error: true run: node scripts/reviews-index.mjs --dir=docs/reviews --check - # Конвейер читает `process.yml` из ветки по умолчанию, поэтому файл обязан - # совпадать в `main` и `dev`. До этой проверки совпадение держалось на - # дисциплине: каждая правка требовала двух пушей и ручной сверки. - - name: "Процесс: process.yml и mutation-gate.yml идентичны в main и dev" + # #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 - # #472: расписание mutation-gate.yml тоже исполняется из ветки по - # умолчанию — та же ловушка, что у process.yml; #636 добавил - # process-resume.yml (событие workflow_run). Сверяются все три. status=0 - for file in process.yml mutation-gate.yml process-resume.yml; do + 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 "правку нужно отправить в обе ветки." + echo "Тонкий вызывающий файл исполняется из ветки по умолчанию," + echo "поэтому правку триггеров или прав нужно зеркалить в main (#623)." status=1 fi done @@ -224,7 +230,7 @@ jobs: } echo "### Предполётные проверки" >> "$GITHUB_STEP_SUMMARY" check "документация" "$DOCS" - check "process.yml и mutation-gate.yml в main и dev" "$WORKFLOW_SYNC" + check "тонкие вызывающие workflow в main и dev" "$WORKFLOW_SYNC" check "провенанс коммитов" "$PROVENANCE" check "процессный гейт" "$PROCESS_GATE" check "пины сторонних Actions" "$ACTION_PINS" diff --git a/AGENTS.md b/AGENTS.md index cedf55b5..2bad9101 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -466,6 +466,18 @@ cancelled by the next push), and by the beta every issue has already been mutated twice — on review and on the rebased merge candidate; the night runs the full registry (`mutation-gate.yml`), not a diff subset. +**Workflows run from the default branch are thin callers (#623).** For +`issues`, `schedule` and `workflow_run` GitHub executes the file from `main`. +The six such files (`process.yml`, `process-resume.yml`, +`process-reconcile.yml`, `mutation-gate.yml`, `nightly.yml`, +`process-metrics.yml`) carry only triggers, run-name, permissions and +concurrency and call their body `_.yml` at `@dev` with +`secrets: inherit`. A pipeline change is one commit to `dev` — edit the +`_.yml` body; no mirror into `main`, no merge-back before promotion. +Only a change of triggers, dispatch inputs or the permission ceiling touches the +thin file, and then it is mirrored into `main` (preflight `workflow_sync` +compares exactly these six; `PROCESS.md` §10.4). + The full smoke set, `golden` and `performance_smoke` still belong to the pre-beta run — which is then mandatory and complete. WSL runs of the full HA harness (`~/houseplan-card`, venv) are advisory; **the canon does not move**: diff --git a/PROCESS.md b/PROCESS.md index dcb36b40..06d4bfde 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -990,7 +990,7 @@ SHA. Manifest публикуется и входит в `SHA256SUMS`; свежа ### 10.4 Событийный конвейер: метка как триггер -`.github/workflows/process.yml`, issue #114. Смена статусной метки — не запись в +`.github/workflows/process.yml` (тело — `_process.yml`, #623), issue #114. Смена статусной метки — не запись в журнал, а **сообщение**: она порождает событие, событие запускает следующий шаг. ``` @@ -1012,13 +1012,34 @@ Medium-находки вне скоупа задачи (#202), кладёт до 1. метки переставляет **PAT**, а не `GITHUB_TOKEN`: GitHub намеренно не порождает события от `GITHUB_TOKEN`, чтобы не было циклов, и цепочка обрывалась бы после первого шага без ошибок в логах; -2. `process.yml` обязан лежать в **ветке по умолчанию**: для события `issues` - GitHub берёт workflow только оттуда, независимо от содержимого `dev`; +2. для события `issues` GitHub берёт workflow только из **ветки по умолчанию** + (`main`), независимо от содержимого `dev`. Поэтому в `main` лежит тонкий + `process.yml` — триггер, run-name, потолок прав, — а тело `_process.yml` он + вызывает по ссылке `@dev` (#623). Правило ниже, «Workflow из ветки по + умолчанию», — общее для всех таких файлов; 3. слияние в `dev` происходит **до** простановки `S8-merged`, иначе метка врёт в промежутке — она утверждает, что код в `dev`; 4. многострочный текст внутри `run:` — только через heredoc: строка с нулевым отступом обрывает блок YAML, и скрипт обрезается без ошибки парсера. +**Workflow из ветки по умолчанию: тонкий файл и тело из `dev`** (#623). Для +событий `issues`, `schedule` и `workflow_run` GitHub исполняет workflow из +`main`. Таких файлов шесть: `process.yml`, `process-resume.yml`, +`process-reconcile.yml`, `mutation-gate.yml`, `nightly.yml`, +`process-metrics.yml`. Каждый — тонкий вызывающий: триггеры, run-name, +права, concurrency и одна job `uses: +Matysh/houseplan-card/.github/workflows/_<имя>.yml@dev` с `secrets: inherit`. +Тело `_<имя>.yml` читается из `dev` в момент запуска, поэтому **правка +конвейера — один коммит в `dev`**, зеркало в `main` и возврат `main` в `dev` +перед промоушеном не нужны. Потолок прав вызывающей job равен объединению +прав job тела: вызываемый workflow права только сужает, и каждая job тела +получает прежний минимум (#556). Тонкий файл меняется, только когда меняются +триггеры, входы ручного запуска или потолок прав; тогда он зеркалится в +`main`, и preflight `workflow_sync` в `validate.yml` держит копии равными — +сверяются ровно эти шесть файлов, список держит +`test/default-branch-workflows.test.mjs`. `performance.yml` в список не входит: +по расписанию он судит `main` собственным телом из `main`. + **Ревью не начинается на красном коде** (#510). После фиксации материала конвейер запускает Validate с мутантами по диффу на этом SHA (`scripts/validate-gate.mjs`: `workflow_dispatch validate.yml -f mutants=true`). **Ждёт его не раннер, а событие** diff --git a/docs/process/REVIEWER.md b/docs/process/REVIEWER.md index e43f6130..341d963a 100644 --- a/docs/process/REVIEWER.md +++ b/docs/process/REVIEWER.md @@ -1,7 +1,7 @@ # Конспект для ревьюера Роли: ревьюер ТЗ и ревьюер кода ([§6](../../PROCESS.md#6-роли)). Штатный -ревьюер конвейера получает этот файл из промпта `.github/workflows/process.yml`; +ревьюер конвейера получает этот файл из промпта `.github/workflows/_process.yml`; ручное ревью по просьбе владельца идёт по нему же. > **Это выжимка, а не канон.** Канон процесса — [`PROCESS.md`](../../PROCESS.md); diff --git a/scripts/action-pins.mjs b/scripts/action-pins.mjs index c40aaa7d..42357ba6 100644 --- a/scripts/action-pins.mjs +++ b/scripts/action-pins.mjs @@ -12,6 +12,16 @@ * (`./.github/…`), либо `/[/]@<40 hex>` с комментарием, где * записана человекочитаемая версия — то, что при обновлении сверяет человек. * + * Одно исключение (#623): тело workflow этого же репозитория из ветки `dev` — + * `Matysh/houseplan-card/.github/workflows/_<имя>.yml@dev`. Для событий + * `issues`/`schedule`/`workflow_run` GitHub исполняет файл из `main`, а локальный + * `./…` взял бы тело из того же коммита `main` — и правку конвейера снова + * пришлось бы зеркалить. Это не чужой код: `dev` — наша ветка, её коммиты + * проходят тот же процессный гейт и ревью, что и всё остальное. Исключение + * узкое: только этот репозиторий, только `_*.yml` в `.github/workflows`, только + * ссылка `@dev` и только с комментарием о причине. Любой другой ref или + * репозиторий — прежняя находка. + * * node scripts/action-pins.mjs # проверить * node scripts/action-pins.mjs --list # что и к чему закреплено * @@ -29,6 +39,9 @@ const USES = /^\s*(?:-\s*)?uses:\s*(\S+)(.*)$/; /** Локальная переиспользуемая workflow — не сторонний код, пина не требует. */ export const isLocal = (spec) => spec.startsWith('./'); +/** #623: тело workflow этого репозитория из `dev` — наш код, а не сторонний. */ +export const OWN_DEV_REUSABLE = /^Matysh\/houseplan-card\/\.github\/workflows\/_[\w.-]+\.ya?ml@dev$/; +export const isOwnDevReusable = (spec) => OWN_DEV_REUSABLE.test(spec); export const isPinned = (spec) => /^[\w.-]+\/[\w./-]+@[0-9a-f]{40}$/.test(spec); /** Комментарий обязателен: без него человек не знает, какую версию он закрепил. */ export const hasVersionNote = (tail) => /#\s*\S/.test(tail); @@ -41,6 +54,10 @@ export function auditWorkflowSource(file, source) { const [, spec, tail] = match; const at = `${file}:${index + 1}`; if (isLocal(spec)) return; + if (isOwnDevReusable(spec)) { + if (!hasVersionNote(tail)) problems.push(`${at}: «${spec}» без комментария с причиной ссылки на dev`); + return; + } if (!isPinned(spec)) { problems.push(`${at}: «${spec}» не закреплён полным SHA`); return; diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 92504bb0..92ee3d12 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -4249,7 +4249,7 @@ const MUTANT_DEFINITIONS = [ because: 'a marker written after a red or partial run would let the next night skip the registry ' + 'and never file the failure issue again (#620, #472)', patches: [{ - file: '.github/workflows/mutation-gate.yml', + file: '.github/workflows/_mutation-gate.yml', find: " if: needs.material.outputs.reuse != 'true' && needs.mutants.result == 'success' && needs.evidence.result == 'success'", replace: " if: always() && needs.material.outputs.reuse != 'true'", }], @@ -4260,7 +4260,7 @@ const MUTANT_DEFINITIONS = [ because: 'a skipped night must be proved by a marker; a failed decision step has to mean ' + 'a full run, not a silent skip (#620)', patches: [{ - file: '.github/workflows/mutation-gate.yml', + file: '.github/workflows/_mutation-gate.yml', find: ' echo "reuse=false" >> "$GITHUB_OUTPUT"', replace: ' echo "reuse=true" >> "$GITHUB_OUTPUT"', }], @@ -4620,7 +4620,7 @@ const MUTANT_DEFINITIONS = [ because: 'a nightly that returns green at dispatch time hides a red full run; the job must wait ' + 'for the child and inherit its conclusion (#492 §7)', patches: [{ - file: '.github/workflows/nightly.yml', + file: '.github/workflows/_nightly.yml', find: ' gh run watch "$run_id" --repo "$REPO" --exit-status --interval 30', replace: ' echo "watching skipped" # mutant: dispatch counted as success', }], @@ -8617,7 +8617,7 @@ const MUTANT_DEFINITIONS = [ + 'ветки. Без ожидания шаг возвращается сразу после push, и запуск встаёт на вершину, ' + 'которой на ветке уже нет', patches: [{ - file: '.github/workflows/process.yml', + file: '.github/workflows/_process.yml', find: ' if [ "$seen" = "$after" ]; then settled=true; break; fi', replace: ' settled=true; break', }], @@ -8628,7 +8628,7 @@ const MUTANT_DEFINITIONS = [ because: '#551: модель должна читать exact material, подготовленный до её запуска; checkout ' + 'подвижного dev разрывает контракт между зелёным gate и вердиктом', patches: [{ - file: '.github/workflows/process.yml', + file: '.github/workflows/_process.yml', find: ' ref: ${{ needs.prepare.outputs.material_sha }}', replace: ' ref: dev # mutant: moving material', }], @@ -8639,7 +8639,7 @@ const MUTANT_DEFINITIONS = [ because: '#551: результат JSON-schema review должен дойти до интеграции объектом; ' + 'перенаправление stdout предиката jq записывает boolean true и роняет публикацию', patches: [{ - file: '.github/workflows/process.yml', + file: '.github/workflows/_process.yml', find: ' printf \'%s\' "$OUT" > "$RUNNER_TEMP/verdict.json"', replace: ' printf \'%s\' "$OUT" | jq -e \'true\' > "$RUNNER_TEMP/verdict.json" # mutant: boolean payload', }], @@ -8650,7 +8650,7 @@ const MUTANT_DEFINITIONS = [ because: '#551: structured verdict записан через printf без финального LF; GitHub output ' + 'delimiter обязан начинаться с новой строки, иначе integration падает после зелёной модели', patches: [{ - file: '.github/workflows/process.yml', + file: '.github/workflows/_process.yml', find: ' printf \'\\nEOF_RESULT\\n\' >> "$GITHUB_OUTPUT"', replace: ' printf \'EOF_RESULT\\n\' >> "$GITHUB_OUTPUT" # mutant: delimiter glued to JSON', }], @@ -9113,13 +9113,80 @@ const MUTANT_DEFINITIONS = [ replace: "export const isPinned = (spec) => /^[\\w.-]+\\/[\\w./-]+@\\S+$/.test(spec);", }], }, + // #623: тонкие вызывающие файлы в main и их тела из dev. + { + id: 'own-reusable-accepts-any-ref', + guard: 'node --test --test-name-pattern="#623" test/action-pins.test.mjs', + because: '#623: исключение пина для тела из dev этого репозитория, расширенное на любой ref, ' + + 'пропустило бы перемещаемую ссылку `@main` или `@feature` мимо #556', + patches: [{ + file: 'scripts/action-pins.mjs', + find: "export const OWN_DEV_REUSABLE = /^Matysh\\/houseplan-card\\/\\.github\\/workflows\\/_[\\w.-]+\\.ya?ml@dev$/;", + replace: "export const OWN_DEV_REUSABLE = /^Matysh\\/houseplan-card\\/\\.github\\/workflows\\/_[\\w.-]+\\.ya?ml@[\\w./-]+$/;", + }], + }, + { + id: 'workflow-sync-forgets-a-thin-caller', + guard: 'node --test test/default-branch-workflows.test.mjs', + because: '#623: preflight сверял 3 файла из 6 исполняемых из main — правка четвёртого ' + + 'доезжала до dev и молча не действовала', + patches: [{ + file: '.github/workflows/validate.yml', + find: ' for file in process.yml mutation-gate.yml process-resume.yml nightly.yml process-reconcile.yml process-metrics.yml; do', + replace: ' for file in process.yml mutation-gate.yml process-resume.yml nightly.yml process-reconcile.yml; do', + }], + }, + { + id: 'thin-caller-runs-the-main-body', + guard: 'node --test test/default-branch-workflows.test.mjs', + because: '#623: ссылка на тело не из dev (локальный `./` или `@main`) исполняла бы копию из ' + + 'main — ровно то зеркалирование, которое снимает задача', + patches: [{ + file: '.github/workflows/process.yml', + find: ' uses: Matysh/houseplan-card/.github/workflows/_process.yml@dev # #623: тело конвейера из dev', + replace: ' uses: ./.github/workflows/_process.yml', + }], + }, + { + id: 'thin-caller-widens-permissions', + guard: 'node --test test/default-branch-workflows.test.mjs', + because: '#623/#556: потолок прав вызывающей job шире объединения прав тела отдал бы ' + + 'лишнее право каждой job тела без собственного блока permissions', + patches: [{ + file: '.github/workflows/mutation-gate.yml', + find: ' issues: write\n uses: Matysh/houseplan-card/.github/workflows/_mutation-gate.yml@dev', + replace: ' issues: write\n contents: write\n uses: Matysh/houseplan-card/.github/workflows/_mutation-gate.yml@dev', + }], + }, + { + id: 'thin-caller-drops-secrets', + guard: 'node --test test/default-branch-workflows.test.mjs', + because: '#623: без `secrets: inherit` тело получает пустые HP_PROCESS_TOKEN и ' + + 'CLAUDE_CODE_OAUTH_TOKEN — конвейер падает на первом шаге с меткой', + patches: [{ + file: '.github/workflows/process-reconcile.yml', + find: ' secrets: inherit\n', + replace: '', + }], + }, + { + id: 'mutation-body-keys-marker-on-caller-sha', + guard: 'node --test test/default-branch-workflows.test.mjs', + because: '#623: в вызываемом workflow github.workflow_sha — SHA вызывающего файла из main; ' + + 'маркер повторного использования перестал бы зависеть от версии тела', + patches: [{ + file: '.github/workflows/_mutation-gate.yml', + find: ' WORKFLOW_SHA: ${{ job.workflow_sha }}', + replace: ' WORKFLOW_SHA: ${{ github.workflow_sha }}', + }], + }, { id: 'review-model-gets-repository-write', guard: 'node --test --test-name-pattern="по job" test/review-doc-guard.test.mjs', because: '#556: недоверенная стадия с правом записи в репозиторий может положить коммит в ' + 'ветку задачи в обход ревью и слияния — потолок прав этой job проверяется дословно', patches: [{ - file: '.github/workflows/process.yml', + file: '.github/workflows/_process.yml', find: ' permissions:\n contents: read\n issues: write\n needs: [guard, prepare]\n', replace: ' permissions:\n contents: write\n issues: write\n needs: [guard, prepare]\n', }], @@ -9131,7 +9198,7 @@ const MUTANT_DEFINITIONS = [ + 'собственный App-токен с дефолтом contents/issues/pull_requests: write, и объявленные ' + '`permissions:` недоверенной стадии перестают быть потолком — молча, зелёным прогоном', patches: [{ - file: '.github/workflows/process.yml', + file: '.github/workflows/_process.yml', find: ' github_token: ${{ secrets.GITHUB_TOKEN }}\n', replace: '', }], @@ -9142,7 +9209,7 @@ const MUTANT_DEFINITIONS = [ because: '#551: timeout/cancel/failure модели не является вердиктом; интеграция обязана ' + 'остановиться, сохранить метку и назвать упавшую стадию', patches: [{ - file: '.github/workflows/process.yml', + file: '.github/workflows/_process.yml', find: ' if [ "$REUSE" != "true" ] && [ "$MODEL_RESULT" != "success" ]; then', replace: ' if false; then # mutant: every model result is accepted', }], diff --git a/test/action-pins.test.mjs b/test/action-pins.test.mjs index 8cab2ca4..bf18f891 100644 --- a/test/action-pins.test.mjs +++ b/test/action-pins.test.mjs @@ -8,7 +8,7 @@ import test from 'node:test'; import { readFileSync } from 'node:fs'; import { - auditRepository, auditWorkflowSource, isLocal, isPinned, hasVersionNote, listWorkflows, + auditRepository, auditWorkflowSource, isLocal, isOwnDevReusable, isPinned, hasVersionNote, listWorkflows, } from '../scripts/action-pins.mjs'; test('#556: в репозитории не осталось незакреплённых Actions', () => { @@ -65,3 +65,32 @@ test('#556: preflight Validate считает пины частью вердик assert.match(preflight, /^\s+check "пины сторонних Actions" "\$ACTION_PINS"$/m, 'исход попадает в вердикт, а не теряется в continue-on-error'); }); + +// #623: тонкие файлы в main вызывают тело из dev этого же репозитория. Исключение +// обязано быть узким: другой ref, другой репозиторий или файл вне `_*.yml` +// остаются находкой, иначе «@dev» стал бы дверью для любой перемещаемой ссылки. +test('#623: тело этого репозитория по ссылке @dev пина не требует, но требует причины', () => { + const own = 'Matysh/houseplan-card/.github/workflows/_process.yml@dev'; + assert.equal(isOwnDevReusable(own), true); + assert.deepEqual(auditWorkflowSource('x.yml', ` uses: ${own} # #623: тело из dev\n`), []); + const bare = auditWorkflowSource('x.yml', ` uses: ${own}\n`); + assert.equal(bare.length, 1); + assert.match(bare[0], /без комментария с причиной/); +}); + +test('#623: исключение @dev не распространяется на другие ref, репозитории и пути', () => { + for (const spec of [ + 'Matysh/houseplan-card/.github/workflows/_process.yml@main', + 'Matysh/houseplan-card/.github/workflows/_process.yml@issue/623-x', + 'Matysh/houseplan-card/.github/workflows/process.yml@dev', + 'Matysh/houseplan-card/scripts/_x.yml@dev', + 'Matysh/houseplan-card-fork/.github/workflows/_process.yml@dev', + 'Other/houseplan-card/.github/workflows/_process.yml@dev', + 'actions/checkout@dev', + ]) { + assert.equal(isOwnDevReusable(spec), false, spec); + const found = auditWorkflowSource('x.yml', ` uses: ${spec} # comment\n`); + assert.equal(found.length, 1, spec); + assert.match(found[0], /не закреплён полным SHA/, spec); + } +}); diff --git a/test/default-branch-workflows.test.mjs b/test/default-branch-workflows.test.mjs new file mode 100644 index 00000000..743b5651 --- /dev/null +++ b/test/default-branch-workflows.test.mjs @@ -0,0 +1,228 @@ +// #623: workflow, которые GitHub исполняет из ветки по умолчанию. +// +// Для `issues`, `schedule`, `workflow_run` и ещё ряда событий GitHub берёт файл +// workflow только из `main`. До #623 тела конвейера лежали в этих файлах +// целиком, и каждая правка требовала зеркального коммита в `main`: 26 mirror- +// коммитов и 13 merge-back за месяц, а preflight сверял 3 файла из 6. +// +// Теперь в `main` живут тонкие вызывающие файлы — триггеры, run-name, права, +// concurrency, — а тело `_<имя>.yml` они вызывают по ссылке `@dev`. Этот тест +// держит проводку: каждый такой файл тонкий, вызывает своё тело из dev, передаёт +// секреты и входы, не расширяет права, а preflight сверяет ровно их. +// +// Полноценного YAML-парсера в зависимостях нет (см. scripts/workflow-jobs.mjs): +// разбирается ровно та блочная структура, которую пишут эти файлы; всё +// неожиданное — громкая ошибка разбора, а не догадка. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; + +const DIR = fileURLToPath(new URL('../.github/workflows/', import.meta.url)); +const read = (name) => readFileSync(join(DIR, name), 'utf8'); +const OWN_REPO = 'Matysh/houseplan-card'; + +// События, для которых GitHub берёт workflow из ветки по умолчанию (или из базы +// PR — `pull_request_target`), а не из коммита, который их породил. +const DEFAULT_BRANCH_EVENTS = new Set([ + 'branch_protection_rule', 'check_run', 'check_suite', 'delete', 'discussion', + 'discussion_comment', 'fork', 'gollum', 'issue_comment', 'issues', 'label', + 'milestone', 'page_build', 'project', 'project_card', 'project_column', 'public', + 'pull_request_target', 'registry_package', 'repository_dispatch', 'schedule', + 'status', 'watch', 'workflow_run', +]); + +// performance.yml по расписанию судит `main` собственным телом из `main`: +// исполняемая копия и есть та, что лежит рядом с проверяемым кодом. +const SELF_CONSISTENT = new Set(['performance.yml']); + +const isComment = (line) => !line.trim() || line.trimStart().startsWith('#'); +const indentOf = (line) => line.length - line.trimStart().length; + +/** Верхнеуровневые блоки: ключ → строки блока без строки-заголовка. */ +function topBlocks(text) { + const blocks = new Map(); + let current = null; + for (const line of text.split('\n')) { + if (isComment(line) && indentOf(line) === 0) { if (current && !line.trim()) current.push(line); continue; } + const key = /^([\w-]+):(.*)$/.exec(line); + if (key && indentOf(line) === 0) { + current = []; + blocks.set(key[1], current); + if (key[2].trim() && !/^\s*#/.test(key[2])) current.push(` ${key[2].trim()}`); + continue; + } + if (current) current.push(line); + } + return blocks; +} + +/** Ключи на заданном отступе внутри блока: ключ → строки под ним. */ +function children(lines, indent) { + const out = new Map(); + let current = null; + for (const line of lines) { + if (isComment(line)) continue; + const at = indentOf(line); + if (at < indent) break; + if (at === indent) { + const key = /^\s*([\w-]+):\s*(.*?)\s*(?:#.*)?$/.exec(line); + assert.ok(key, `не разобрана строка ${JSON.stringify(line)}`); + current = []; + out.set(key[1], current); + if (key[2]) current.value = key[2]; + continue; + } + if (current) current.push(line); + } + return out; +} + +/** Плоская карта `scope: level` на заданном отступе. */ +function permissionMap(lines, indent) { + const map = {}; + for (const [scope, rest] of children(lines, indent)) { + assert.equal(rest.length, 0, `права ${scope}: вложенность не ожидается`); + map[scope] = rest.value; + } + return map; +} + +const LEVEL = { none: 0, read: 1, write: 2 }; +function unionPermissions(maps) { + const out = {}; + for (const map of maps) { + for (const [scope, level] of Object.entries(map)) { + assert.ok(level in LEVEL, `уровень ${scope}: ${level}`); + if (!(scope in out) || LEVEL[level] > LEVEL[out[scope]]) out[scope] = level; + } + } + return out; +} +const sorted = (map) => Object.fromEntries(Object.entries(map).sort(([a], [b]) => a.localeCompare(b))); + +function triggers(name) { + const on = topBlocks(read(name)).get('on'); + assert.ok(on, `${name}: нет блока on:`); + assert.ok(!on.some((line) => /^\s{2}\[/.test(line)), `${name}: on: в строку не поддерживается`); + return [...children(on, 2).keys()]; +} + +const workflows = readdirSync(DIR).filter((name) => /\.ya?ml$/.test(name)).sort(); +const bodies = workflows.filter((name) => name.startsWith('_')); +const fromDefaultBranch = workflows + .filter((name) => !name.startsWith('_') && triggers(name).some((event) => DEFAULT_BRANCH_EVENTS.has(event))); +const THIN = fromDefaultBranch.filter((name) => !SELF_CONSISTENT.has(name)); + +function syncList() { + const validate = read('validate.yml'); + const step = validate.slice(validate.indexOf('id: workflow_sync')); + const loop = /for file in ([^;\n]+); do/.exec(step); + assert.ok(loop, 'preflight workflow_sync перебирает список файлов'); + return loop[1].trim().split(/\s+/); +} + +test('#623: исполняемые из main файлы найдены по триггерам, а не по памяти', () => { + assert.deepEqual(THIN, [ + 'mutation-gate.yml', 'nightly.yml', 'process-metrics.yml', 'process-reconcile.yml', + 'process-resume.yml', 'process.yml', + ]); + for (const name of SELF_CONSISTENT) assert.ok(fromDefaultBranch.includes(name), `${name}: исключение без причины`); +}); + +test('#623: preflight сверяет в main и dev ровно тонкие вызывающие файлы', () => { + const list = syncList(); + assert.equal(new Set(list).size, list.length, 'в списке сверки нет повторов'); + assert.deepEqual([...list].sort(), THIN, 'список сверки = файлы, исполняемые из main'); +}); + +test('#623: performance.yml из main судит main — сверка ему не нужна', () => { + const name = 'performance.yml'; + const events = triggers(name).filter((event) => DEFAULT_BRANCH_EVENTS.has(event)); + assert.deepEqual(events, ['schedule'], 'из ветки по умолчанию — только расписание'); + const text = read(name); + assert.match(text, /push:\n\s+branches:\n\s+- main\n/); + assert.ok(!/ref: dev\b/.test(text), 'кандидат — коммит самого прогона, а не голова dev'); +}); + +for (const name of THIN) { + const body = `_${name}`; + + test(`#623: ${name} — тонкий файл, вызывающий ${body} из dev`, () => { + const caller = topBlocks(read(name)); + for (const key of caller.keys()) { + assert.ok(['name', 'run-name', 'on', 'permissions', 'concurrency', 'jobs'].includes(key), `${name}: лишний ключ ${key}`); + } + const jobs = children(caller.get('jobs'), 2); + assert.equal(jobs.size, 1, `${name}: ровно одна вызывающая job`); + const [job] = jobs.values(); + const keys = children(job, 4); + for (const key of keys.keys()) { + assert.ok(['name', 'if', 'permissions', 'uses', 'with', 'secrets'].includes(key), `${name}: у вызывающей job лишний ключ ${key}`); + } + assert.equal(keys.get('uses')?.value, `${OWN_REPO}/.github/workflows/${body}@dev`, `${name}: тело берётся из dev`); + assert.equal(keys.get('secrets')?.value, 'inherit', `${name}: секреты тела наследуются`); + assert.ok(existsSync(join(DIR, body)), `${body} существует`); + }); + + test(`#623: ${body} — только workflow_call, без run-name и concurrency уровня workflow`, () => { + const blocks = topBlocks(read(body)); + assert.deepEqual(triggers(body), ['workflow_call'], `${body}: не запускается сам по себе`); + assert.ok(!blocks.has('run-name'), `${body}: run-name живёт у вызывающего`); + assert.ok(!blocks.has('concurrency'), `${body}: concurrency уровня workflow живёт у вызывающего`); + assert.ok(!/\$\{\{[^}]*\bgithub\.workflow_sha\b/.test(read(body)), + `${body}: в вызываемом workflow github.workflow_sha — SHA вызывающего из main, не тела`); + }); + + test(`#623: ${name} не расширяет права тела и не сужает их`, () => { + const caller = topBlocks(read(name)); + const callee = topBlocks(read(body)); + const callerTop = permissionMap(caller.get('permissions'), 2); + const calleeTop = permissionMap(callee.get('permissions'), 2); + assert.deepEqual(sorted(callerTop), sorted(calleeTop), 'права уровня workflow совпадают'); + const effective = [...children(callee.get('jobs'), 2).values()].map((job) => { + const own = children(job, 4).get('permissions'); + return own ? permissionMap(own, 6) : calleeTop; + }); + const [callerJob] = children(caller.get('jobs'), 2).values(); + const granted = permissionMap(children(callerJob, 4).get('permissions') || [], 6); + assert.deepEqual(sorted(granted), sorted(unionPermissions(effective)), + 'потолок вызывающей job = объединение прав job тела: шире — лишнее, уже — отказ GitHub на старте'); + }); + + test(`#623: ${name} передаёт телу каждый вход ручного запуска`, () => { + const caller = topBlocks(read(name)); + const dispatch = children(caller.get('on'), 2).get('workflow_dispatch') || []; + const dispatchInputs = [...(children(dispatch, 4).get('inputs') ? children(children(dispatch, 4).get('inputs'), 6).keys() : [])]; + const call = children(topBlocks(read(body)).get('on'), 2).get('workflow_call') || []; + const callInputs = [...(children(call, 4).get('inputs') ? children(children(call, 4).get('inputs'), 6).keys() : [])]; + assert.deepEqual(callInputs.sort(), [...dispatchInputs].sort(), `${body}: те же входы, что у ручного запуска`); + const [callerJob] = children(caller.get('jobs'), 2).values(); + const withBlock = children(callerJob, 4).get('with') || []; + const forwarded = children(withBlock, 6); + assert.deepEqual([...forwarded.keys()].sort(), [...dispatchInputs].sort(), `${name}: передаётся каждый вход`); + for (const [input, rest] of forwarded) { + assert.match(rest.value, new RegExp(`^\\$\\{\\{ inputs\\.${input}\\b`), `${name}: ${input} берётся из входа запуска`); + } + }); + + test(`#623: фильтр вызывающей job ${name} совпадает с фильтром тела`, () => { + const [callerJob] = children(topBlocks(read(name)).get('jobs'), 2).values(); + const condition = children(callerJob, 4).get('if')?.value; + if (!condition) return; + const [first] = children(topBlocks(read(body)).get('jobs'), 2).values(); + assert.equal(children(first, 4).get('if')?.value, condition, + `${name}: if повторяет стража тела — иначе посторонние события либо поднимут тело, либо потеряют запуск`); + }); +} + +test('#623: у каждого тела есть свой тонкий вызывающий, других вызовов нет', () => { + assert.deepEqual(bodies, THIN.map((name) => `_${name}`).sort()); + for (const name of workflows) { + const calls = [...read(name).matchAll(/uses:\s*(\S*\/\.github\/workflows\/_[\w.-]+\.ya?ml@\S+|\.\/\.github\/workflows\/_[\w.-]+\.ya?ml)/g)] + .map((match) => match[1]); + const expected = THIN.includes(name) ? [`${OWN_REPO}/.github/workflows/_${name}@dev`] : []; + assert.deepEqual(calls, expected, `${name}: тела вызываются только своими тонкими файлами`); + } +}); diff --git a/test/mutation-gate.test.mjs b/test/mutation-gate.test.mjs index 48c7ecc4..129e7e26 100644 --- a/test/mutation-gate.test.mjs +++ b/test/mutation-gate.test.mjs @@ -17,7 +17,7 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url)); // Дешёвая половина гейта, идёт с обычными юнитами на каждом прогоне. Полный // прогон с пересборкой бандла на мутанта — ночное расписание (#513), он в -// .github/workflows/mutation-gate.yml. +// .github/workflows/_mutation-gate.yml. // // Реестр, отставший от кода, хуже отсутствующего: он выглядит защитой. Поэтому // дрейф якорей ловится здесь, а не при редком полном прогоне. @@ -281,6 +281,10 @@ test('#458 у каждого модуля горячего пути отрисо // всё позволяла ручному запуску молча отменить расписание. import { readFileSync as readWorkflowFile } from 'node:fs'; const mutationWorkflow = readWorkflowFile( + new URL('../.github/workflows/_mutation-gate.yml', import.meta.url), 'utf8', +); +// #623: триггеры и concurrency — у тонкого вызывающего файла, тело — в `_mutation-gate.yml`. +const mutationCaller = readWorkflowFile( new URL('../.github/workflows/mutation-gate.yml', import.meta.url), 'utf8', ); const validateWorkflowText = readWorkflowFile( @@ -288,13 +292,13 @@ const validateWorkflowText = readWorkflowFile( ); test('#513 AC1: полный мутационный прогон идёт каждую ночь, не раз в неделю и не перед релизом', () => { - assert.match(mutationWorkflow, /- cron: '0 1 \* \* \*'/, 'ежедневно 01:00 UTC'); - assert.ok(!/cron: '[^']*\* [0-6]'/.test(mutationWorkflow), 'недельного расписания (день недели) быть не должно'); + assert.match(mutationCaller, /- cron: '0 1 \* \* \*'/, 'ежедневно 01:00 UTC'); + assert.ok(!/cron: '[^']*\* [0-6]'/.test(mutationCaller), 'недельного расписания (день недели) быть не должно'); assert.ok(!mutationWorkflow.includes('перед стабильным релизом'), 'полный прогон — не шаг релиза'); }); test('#472 AC1: у расписания и ручного запуска разные concurrency-группы', () => { - assert.match(mutationWorkflow, /group: mutation-gate-\$\{\{ github\.event_name \}\}/); + assert.match(mutationCaller, /^concurrency:\n group: mutation-gate-\$\{\{ github\.event_name \}\}/m); }); test('#472 AC2 / #549: каждый шард сохраняет лог и identity при любом исходе', () => { @@ -358,11 +362,11 @@ test('#549: агрегатор требует четыре evidence одного assert.match(evidence, /ref: \$\{\{ needs\.material\.outputs\.sha \}\}/); assert.match(evidence, /--sha=\$\{\{ needs\.material\.outputs\.sha \}\}/); assert.match(evidence, /--tree=\$\{\{ needs\.material\.outputs\.tree \}\}/); - assert.match(evidence, /--workflow-sha=\$\{\{ github\.workflow_sha \}\}/); + assert.match(evidence, /--workflow-sha=\$\{\{ job\.workflow_sha \}\}/); assert.match(evidence, /--run-id=\$\{\{ github\.run_id \}\} --run-attempt=\$\{\{ github\.run_attempt \}\}/); assert.match(report, /--require-evidence/); assert.match(report, /ref: \$\{\{ needs\.material\.outputs\.sha \}\}/); - assert.match(report, /--workflow-sha=\$\{\{ github\.workflow_sha \}\}/); + assert.match(report, /--workflow-sha=\$\{\{ job\.workflow_sha \}\}/); assert.ok(!report.includes('git rev-parse HEAD')); assert.ok(!report.includes('ref: dev')); assert.ok(!report.includes('ref: ${{ github.sha }}'), 'main может не содержать dev-CLI отчётчика'); @@ -381,8 +385,8 @@ test('#472 AC7: отсутствие Telegram-секретов не роняет assert.match(telegram, /if \[ -z "\$TOKEN" \] \|\| \[ -z "\$CHAT" \]; then\n\s+echo "::warning::[^\n]*"\n\s+exit 0/); }); -test('#472 AC8: Validate сверяет mutation-gate.yml между main и dev наравне с process.yml', () => { - assert.match(validateWorkflowText, /for file in process\.yml mutation-gate\.yml process-resume\.yml; do/); +test('#472 AC8 / #623: Validate сверяет тонкий mutation-gate.yml между main и dev наравне с process.yml', () => { + assert.match(validateWorkflowText, /for file in process\.yml mutation-gate\.yml process-resume\.yml [^\n]*; do/); }); // #475. Свидетель гниёт двумя способами: изменился файл, который он патчит, @@ -1022,7 +1026,7 @@ test('#620: пропуск ночи — только по маркеру, мар const restore = material.slice(material.indexOf('actions/cache/restore@'), material.indexOf('- name: Нужен ли прогон')); assert.match(material.slice(material.lastIndexOf('- name:', material.indexOf('actions/cache/restore@'))), /^- name: [^\n]*\n\s+if: github\.event_name == 'schedule'\n/); - assert.match(restore, /restore-keys: \|\n\s+mutation-green-v1-\$\{\{ steps\.identity\.outputs\.tree \}\}-\$\{\{ github\.workflow_sha \}\}-\n/); + assert.match(restore, /restore-keys: \|\n\s+mutation-green-v1-\$\{\{ steps\.identity\.outputs\.tree \}\}-\$\{\{ job\.workflow_sha \}\}-\n/); // Сбой решения — полный прогон. assert.match(material, /else\n\s+echo "::warning::[^\n]*"\n\s+echo "reuse=false" >> "\$GITHUB_OUTPUT"/); assert.match(material, /--decide/); @@ -1030,7 +1034,7 @@ test('#620: пропуск ночи — только по маркеру, мар assert.match(evidence, /\n if: always\(\) && needs\.material\.outputs\.reuse != 'true'\n/); assert.match(marker, /\n if: needs\.material\.outputs\.reuse != 'true' && needs\.mutants\.result == 'success' && needs\.evidence\.result == 'success'\n/); assert.match(marker, /--write-marker=artifacts\/mutation-green\/marker\.json/); - assert.match(marker, /key: mutation-green-v1-\$\{\{ needs\.material\.outputs\.tree \}\}-\$\{\{ github\.workflow_sha \}\}-\$\{\{ github\.run_id \}\}/); + assert.match(marker, /key: mutation-green-v1-\$\{\{ needs\.material\.outputs\.tree \}\}-\$\{\{ job\.workflow_sha \}\}-\$\{\{ github\.run_id \}\}/); // Отказ по-прежнему заводит issue; не заводит только принятое доказательство. assert.match(report, /\n if: always\(\) && github\.event_name == 'schedule' && needs\.material\.outputs\.reuse != 'true' && \(needs\.mutants\.result != 'success' \|\| needs\.evidence\.result != 'success'\)\n/); }); diff --git a/test/nightly-workflow.test.mjs b/test/nightly-workflow.test.mjs index f4cc6423..2392f58d 100644 --- a/test/nightly-workflow.test.mjs +++ b/test/nightly-workflow.test.mjs @@ -8,7 +8,7 @@ import { readFileSync } from 'node:fs'; const read = (name) => readFileSync(new URL(`../.github/workflows/${name}`, import.meta.url), 'utf8'); test('nightly ждёт запущенный Validate и падает вместе с ним (#492 §7)', () => { - const nightly = read('nightly.yml'); + const nightly = read('_nightly.yml'); assert.match(nightly, /gh workflow run validate\.yml --repo "\$REPO" --ref dev -f full=true/); // найти именно свой прогон: dispatch на dev, созданный не раньше запуска assert.match(nightly, /gh run list --repo "\$REPO" --workflow validate\.yml --branch dev/); @@ -23,7 +23,7 @@ test('nightly ждёт запущенный Validate и падает вмест }); test('ночная job носит русское имя и не выдаёт очередь за результат (#327, #492)', () => { - const nightly = read('nightly.yml'); + const nightly = read('_nightly.yml'); assert.match(nightly, /name: "Запустить Validate на dev с полным набором и дождаться результата"/); assert.ok(!/поставлен в очередь[^\n]*\n\s*$/.test(nightly), 'echo про очередь не может быть последним шагом'); }); diff --git a/test/process-digests.test.mjs b/test/process-digests.test.mjs index 986cb6e0..0855a8a9 100644 --- a/test/process-digests.test.mjs +++ b/test/process-digests.test.mjs @@ -154,7 +154,7 @@ test('#634 конспект: объявляет себя выжимкой, ка }); test('#634 промпт ревьюера: конспект вместо пересказа, машинные требования на месте', () => { - const workflow = read('.github/workflows/process.yml'); + const workflow = read('.github/workflows/_process.yml'); const start = workflow.indexOf(' prompt: |\n'); const end = workflow.indexOf(' claude_args: |', start); assert.ok(start > 0 && end > start, 'блок prompt найден'); diff --git a/test/process-metrics.test.mjs b/test/process-metrics.test.mjs index 073e088e..b2139d06 100644 --- a/test/process-metrics.test.mjs +++ b/test/process-metrics.test.mjs @@ -129,9 +129,12 @@ test('#637 buildReport: нулевая медиана не смешиваетс }); test('#637 workflow: еженедельный запуск читает только, публикует summary и artifact', () => { - const wf = readFileSync(new URL('../.github/workflows/process-metrics.yml', import.meta.url), 'utf8'); - assert.match(wf, /schedule:\n(?:\s+#[^\n]*\n)*\s+- cron: '/); - assert.match(wf, /workflow_dispatch:/); + const wf = readFileSync(new URL('../.github/workflows/_process-metrics.yml', import.meta.url), 'utf8'); + // #623: расписание — у тонкого вызывающего файла, тело — в `_process-metrics.yml`. + const caller = readFileSync(new URL('../.github/workflows/process-metrics.yml', import.meta.url), 'utf8'); + assert.match(caller, /schedule:\n(?:\s+#[^\n]*\n)*\s+- cron: '/); + assert.match(caller, /workflow_dispatch:/); + assert.ok(!/issues: write/.test(caller), 'потолок прав вызывающего тоже без записи в issue'); assert.match(wf, /permissions:\n\s+contents: read\n\s+actions: read\n\s+issues: read/); assert.match(wf, /node scripts\/process-metrics\.mjs[\s\S]*--output=artifacts\/process-metrics\/report\.md/); assert.match(wf, /GITHUB_STEP_SUMMARY/); diff --git a/test/process-resume.test.mjs b/test/process-resume.test.mjs index 3bf97150..a8db13e8 100644 --- a/test/process-resume.test.mjs +++ b/test/process-resume.test.mjs @@ -93,19 +93,22 @@ test('#636 resume() relabels only on a resume decision and only with apply', asy }); test('#636 workflows: prepare exits pending with a sealed marker, resume relabels by the marker, preflight mirrors the new file', () => { - const process = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); - const resumeWf = readFileSync(new URL('../.github/workflows/process-resume.yml', import.meta.url), 'utf8'); + const process = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); + const resumeWf = readFileSync(new URL('../.github/workflows/_process-resume.yml', import.meta.url), 'utf8'); + // #623: триггер — у тонкого вызывающего файла, тело — в `_process-resume.yml`. + const resumeCaller = readFileSync(new URL('../.github/workflows/process-resume.yml', import.meta.url), 'utf8'); const validate = readFileSync(new URL('../.github/workflows/validate.yml', import.meta.url), 'utf8'); assert.match(process, /validate-gate\.mjs --repo="\$\{\{ github\.repository \}\}" --ref="\$BRANCH" --sha="\$SHA" --no-wait/); assert.match(process, /2\) echo 'proceed=pending' >> "\$GITHUB_OUTPUT"/); assert.match(process, /review-pending-\$\{NUM\}-\$\{GITHUB_RUN_ID\}-\$\{GITHUB_RUN_ATTEMPT\}/); assert.match(process, /sha256sum pending\.json > manifest\.sha256/); assert.match(process, /Validate красный — вернуть автору без ревью\n\s+if: steps\.rebase\.outputs\.conflict != 'true' && steps\.gate\.outputs\.proceed == 'false'/); - assert.match(resumeWf, /workflow_run:\n\s+workflows: \["Проверка \(CI\)"\]\n\s+types: \[completed\]/); + assert.match(resumeCaller, /workflow_run:\n\s+workflows: \["Проверка \(CI\)"\]\n\s+types: \[completed\]/); assert.match(resumeWf, /github\.event\.workflow_run\.event == 'workflow_dispatch' && startsWith\(github\.event\.workflow_run\.head_branch, 'issue\/'\)/); assert.match(resumeWf, /GH_TOKEN: \$\{\{ secrets\.HP_PROCESS_TOKEN \}\}/); assert.match(resumeWf, /node scripts\/process-resume\.mjs/); assert.ok(!/issues: write/.test(resumeWf), 'resume relabels with HP_PROCESS_TOKEN only'); - assert.match(validate, /for file in process\.yml mutation-gate\.yml process-resume\.yml; do/); + assert.ok(!/issues: write/.test(resumeCaller), 'the caller ceiling does not grant issues: write either'); + assert.match(validate, /for file in process\.yml mutation-gate\.yml process-resume\.yml [^\n]*; do/); assert.equal(readFileSync(new URL('../.github/workflows/validate.yml', import.meta.url), 'utf8').includes('name: Проверка (CI)'), true, 'workflow_run listens to the Validate workflow name'); }); diff --git a/test/rebase-generated.test.mjs b/test/rebase-generated.test.mjs index 289a6fc6..fc145a38 100644 --- a/test/rebase-generated.test.mjs +++ b/test/rebase-generated.test.mjs @@ -200,7 +200,7 @@ test('#643 CLI: отказ — код 3 и по строке на конфлик // ---------- проводка в process.yml: свидетели и настоящий bash ---------- -const WORKFLOW = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); +const WORKFLOW = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); const rebaseStep = () => WORKFLOW.slice( WORKFLOW.indexOf(' - name: Привести ветку к dev\n'), WORKFLOW.indexOf(' - name: Зафиксировать SHA материала ревью'), diff --git a/test/review-doc-guard.test.mjs b/test/review-doc-guard.test.mjs index ec751789..a844a56e 100644 --- a/test/review-doc-guard.test.mjs +++ b/test/review-doc-guard.test.mjs @@ -63,7 +63,7 @@ test('allowlist задаётся снаружи и по умолчанию то test('шаг публикации в конвейере проверяет и индекс, и то, что уедет (#365 AC4)', () => { const workflow = readFileSync( - new URL('../.github/workflows/process.yml', import.meta.url), 'utf8', + new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8', ); const step = workflow.slice( workflow.indexOf('- name: Опубликовать документ ревью'), @@ -448,7 +448,7 @@ test('момент коллизии на #449: файлы дали бы своб test('guard считает раунды скриптом, а не inline-shell (#454 AC9)', () => { const workflow = readFileSync( - new URL('../.github/workflows/process.yml', import.meta.url), 'utf8', + new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8', ); const step = workflow.slice( workflow.indexOf(' - id: decide'), @@ -575,7 +575,7 @@ test('изменённое дерево, жёлтый вердикт, High>0 и }); test('конвейер: посторонняя метка не входит в concurrency, guard читает текущие метки (#499)', () => { - const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); + const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); // Concurrency — на job, не на workflow: иначе любой `labeled` вытеснял ожидающий S7. const head = workflow.slice(0, workflow.indexOf('\njobs:')); assert.doesNotMatch(head, /^concurrency:/m, 'concurrency на уровне workflow снова пустит в группу все метки'); @@ -594,7 +594,7 @@ test('конвейер: посторонняя метка не входит в c }); test('конвейер: зелёный вердикт применяется повторно без модели, вердикт пишется в якоря (#499)', () => { - const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); + const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); assert.match(workflow, /review-doc-guard\.mjs --reuse --marker=CODE-REVIEW --num="\$NUM" --head=HEAD/); const modelJob = workflow.slice(workflow.indexOf('\n model_review:'), workflow.indexOf('\n integrate:')); assert.match(modelJob, /if: needs\.prepare\.outputs\.proceed == 'true' && needs\.prepare\.outputs\.reuse != 'true'/, @@ -608,7 +608,7 @@ test('конвейер: зелёный вердикт применяется п }); test('#510 AC2: конвейер запускает Validate с мутантами на материале и не ревьюит красный', () => { - const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); + const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); const at = (marker) => { const i = workflow.indexOf(marker); assert.ok(i > 0, `нет «${marker}»`); return i; }; const material = at(' - name: Зафиксировать SHA материала ревью\n'); const reuse = at(' - name: "Зелёный вердикт прошлого захода применим без ревью (#499)"\n'); @@ -641,7 +641,7 @@ test('#510 AC2: конвейер запускает Validate с мутантам }); test('#515: якоря материала снимаются ПОСЛЕ ребейза конвейером и публикуются из шага material', () => { - const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); + const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); const at = (marker) => { const i = workflow.indexOf(marker); assert.ok(i > 0, `нет «${marker}»`); return i; }; const rebase = at(' - name: Привести ветку к dev\n'); const material = at(' - name: Зафиксировать SHA материала ревью\n'); @@ -716,7 +716,7 @@ test('#517 AC6: reuse не применяет зелёный вердикт, е }); test('#517: конвейер снимает хеш тела на материале и передаёт его в якоря, reuse и ревьюеру', () => { - const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); + const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); const at = (marker) => { const i = workflow.indexOf(marker); assert.ok(i > 0, `нет «${marker}»`); return i; }; const material = at(' - name: Зафиксировать SHA материала ревью\n'); const reuse = at(' - name: "Зелёный вердикт прошлого захода применим без ревью (#499)"\n'); @@ -773,7 +773,9 @@ test('#553: канон разделяет review и исполнение тес test('#555: bounded reconciler wakes only lost review requests and emits one machine summary', () => { const read = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8'); - const workflow = read('.github/workflows/process-reconcile.yml'); + const workflow = read('.github/workflows/_process-reconcile.yml'); + // #623: run-name и расписание — у тонких вызывающих файлов, тела — в `_*.yml`. + const caller = read('.github/workflows/process-reconcile.yml'); const processWorkflow = read('.github/workflows/process.yml'); const process = read('PROCESS.md'); const agents = read('AGENTS.md'); @@ -781,8 +783,8 @@ test('#555: bounded reconciler wakes only lost review requests and emits one mac assert.match(processWorkflow, /run-name: "process #\$\{\{ github\.event\.issue\.number \}\} · \$\{\{ github\.event\.label\.name \}\}/, 'run identity includes issue and requested stage'); - assert.match(workflow, /cron: '7,37 \* \* \* \*'/); - assert.match(workflow, /workflow_dispatch:/); + assert.match(caller, /cron: '7,37 \* \* \* \*'/); + assert.match(caller, /workflow_dispatch:/); assert.match(workflow, /ref: dev/); assert.match(workflow, /secrets\.HP_PROCESS_TOKEN/); assert.match(workflow, /node scripts\/process-reconcile\.mjs[\s\S]*--apply="\$APPLY"/); @@ -796,7 +798,7 @@ test('#555: bounded reconciler wakes only lost review requests and emits one mac }); test('#551: gates, модель и интеграция имеют независимые jobs, contracts и бюджеты', () => { - const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); + const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); const job = (name, next) => { const start = workflow.indexOf(`\n ${name}:`); assert.ok(start > 0, `job ${name} найден`); @@ -868,7 +870,7 @@ test('#551: gates, модель и интеграция имеют незави // обязан дождаться, что ссылка доехала, и спрашивать об этом REST — через него // же идёт диспатч. test('конвейер: ребейз не заканчивается, пока ссылка не укажет на новую вершину (#539)', () => { - const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); + const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); const rebase = workflow.slice( workflow.indexOf(' - name: Привести ветку к dev\n'), workflow.indexOf(' - name: Зафиксировать SHA материала ревью\n'), @@ -891,7 +893,7 @@ test('конвейер: ребейз не заканчивается, пока // `issues: write` и OIDC каждой стадии, включая единственную недоверенную — // работу модели. Права выдаются по job и по факту использования. test('конвейер: права выдаются по job, модель не пишет в issue (#556)', () => { - const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); + const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); const head = workflow.slice(0, workflow.indexOf('\njobs:')); assert.match(head, /^permissions:\n contents: read\n/m, 'на уровне workflow остаётся только чтение'); assert.doesNotMatch(head, /^\s+issues: write$/m, 'issues: write на весь workflow больше не выдаётся'); @@ -920,7 +922,7 @@ test('конвейер: права выдаются по job, модель не // `setupGitHubToken`. Свидетель стоит на проводке, потому что снятие одной // строки возвращает модели запись в репозиторий молча — прогон остаётся зелёным. test('ревью: модель работает job-scoped токеном, а не App-обменом (#556)', () => { - const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); + const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); const model = workflow.slice(workflow.indexOf('\n model_review:\n'), workflow.indexOf('\n integrate:\n')); const review = model.slice(model.indexOf(' - name: Review\n')); const withBlock = review.slice(review.indexOf(' with:'), review.indexOf(' prompt: |')); @@ -952,7 +954,7 @@ test('счёт раундов не зависит от числа файлов }); test('guard перечисляет docs/reviews деревом, а не contents, и без предупреждения о потолке (#621 AC2)', () => { - const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); + const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); const guard = workflow.slice(workflow.indexOf('\n guard:\n'), workflow.indexOf('\n prepare:\n')); assert.doesNotMatch(guard, /contents\/docs\/reviews\?ref=/, 'листинг каталога через contents API снят'); assert.doesNotMatch(guard, /1000 файлов/, 'предупреждение о потолке удалено вместе с зависимостью'); diff --git a/test/review-result-gate.test.mjs b/test/review-result-gate.test.mjs index 3a785ef6..2fcb2c69 100644 --- a/test/review-result-gate.test.mjs +++ b/test/review-result-gate.test.mjs @@ -129,7 +129,7 @@ test('#556: разбор строки sha256sum терпит и пробел, и // Проверка обязана стоять на пути привилегированной стадии, а не просто // существовать в репозитории. test('#556: integrate пропускает artifact только через гейт', () => { - const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); + const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); const integrate = workflow.slice(workflow.indexOf('\n integrate:\n')); const step = integrate.slice( integrate.indexOf(' - name: Проверить полноту и происхождение результата'), diff --git a/test/reviews-index.test.mjs b/test/reviews-index.test.mjs index 6ab73926..2a79cb6f 100644 --- a/test/reviews-index.test.mjs +++ b/test/reviews-index.test.mjs @@ -109,7 +109,7 @@ test('#635 r3: свежесть индекса судится на dev, конв }); test('#635 конвейер пересобирает индекс тем же коммитом, что и документ ревью', () => { - const wf = new URL('../.github/workflows/process.yml', import.meta.url); + const wf = new URL('../.github/workflows/_process.yml', import.meta.url); const text = readFileSync(wf, 'utf8'); assert.match(text, /node scripts\/reviews-index\.mjs --dir=docs\/reviews\n\s+git add -- docs\/reviews\/INDEX\.md/); // r2 H1: после приведения ветки к dev индекс пересобирается коммитом diff --git a/test/validate-workflow.test.mjs b/test/validate-workflow.test.mjs index cc581cc2..3ded84b8 100644 --- a/test/validate-workflow.test.mjs +++ b/test/validate-workflow.test.mjs @@ -406,8 +406,9 @@ test('смоки, golden и performance_smoke условны по heavy (#479)', }); test('ночной прогон — dispatch Validate на dev с full=true (#479)', () => { - const text = read('nightly.yml'); - assert.match(text, /schedule:\n\s+- cron:/); + const text = read('_nightly.yml'); + // #623: расписание — у тонкого вызывающего `nightly.yml`, тело — в `_nightly.yml`. + assert.match(read('nightly.yml'), /schedule:\n\s+- cron:/); assert.match(text, /gh workflow run validate\.yml --repo "\$REPO" --ref dev -f full=true/); assert.match(text, /actions: write/); });