From 58ba44dbac4600bcc54fff00a4c5ee97e60332e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 09:43:01 +0300 Subject: [PATCH 1/9] ci: mirror review-resume workflows in main (#636) Issue: #636 User-Visible: no --- .github/workflows/process-resume.yml | 60 +++++++++++++++++++++++++ .github/workflows/process.yml | 67 +++++++++++++++++++++++++--- .github/workflows/validate.yml | 29 ++++++------ 3 files changed, 137 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/process-resume.yml diff --git a/.github/workflows/process-resume.yml b/.github/workflows/process-resume.yml new file mode 100644 index 00000000..e26cf38a --- /dev/null +++ b/.github/workflows/process-resume.yml @@ -0,0 +1,60 @@ +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. + +on: + workflow_run: + workflows: ["Проверка (CI)"] + types: [completed] + +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 index d1655b00..1813ba7c 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -582,14 +582,64 @@ jobs: { echo 'proceed=true'; echo 'result=skipped'; } >> "$GITHUB_OUTPUT" exit 0 fi - if node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA"; then - echo 'proceed=true' >> "$GITHUB_OUTPUT" - else - echo 'proceed=false' >> "$GITHUB_OUTPUT" - 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 != 'true' + if: steps.rebase.outputs.conflict != 'true' && steps.gate.outputs.proceed == 'false' env: GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} NUM: ${{ github.event.issue.number }} @@ -1181,6 +1231,11 @@ jobs: 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" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 1965d75c..a5b80ccb 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -101,9 +101,10 @@ jobs: run: | git fetch --quiet origin main dev # #472: расписание mutation-gate.yml тоже исполняется из ветки по - # умолчанию — та же ловушка, что у process.yml. Сверяются оба. + # умолчанию — та же ловушка, что у process.yml; #636 добавил + # process-resume.yml (событие workflow_run). Сверяются все три. status=0 - for file in process.yml mutation-gate.yml; do + for file in process.yml mutation-gate.yml process-resume.yml; do if diff <(git show "origin/main:.github/workflows/$file") \ <(git show "origin/dev:.github/workflows/$file"); then echo "$file: main и dev идентичны" @@ -243,7 +244,7 @@ jobs: mutants_requested: ${{ steps.heavy.outputs.mutants_requested }} # #510 base: ${{ steps.base.outputs.base }} # `base` — до какого коммита классифицировать файлы ветки (#387), - # `range_base` — от какого судить диапазон на dev (#388). + # `range_base` — от какого судить диапазон на dev/main (#388, #619). range_base: ${{ steps.base.outputs.range_base }} steps: # `git diff --name-only` содержимого файлов не читает вовсе, поэтому @@ -283,9 +284,10 @@ jobs: gh api -X GET "repos/$REPO/actions/workflows/validate.yml/runs" \ -f branch="$BRANCH" -f status=completed -F per_page=100 \ > /tmp/validate-runs.json || echo '{}' > /tmp/validate-runs.json - if [ "$REF" = "refs/heads/dev" ]; then - # На dev классифицировать нечего (всё true), но база диапазона - # нужна гейту «новый код не добавляет any» в job frontend (#388). + if [ "$REF" = "refs/heads/dev" ] || [ "$REF" = "refs/heads/main" ]; then + # На интеграционных ветках классифицировать нечего (всё true), но + # база диапазона нужна гейту «новый код не добавляет any» в job + # frontend (#388, #619). node scripts/classify-base.mjs --head="$HEAD_SHA" --mode=range \ --name=range_base --fallback="$FALLBACK" --runs=/tmp/validate-runs.json exit 0 @@ -302,8 +304,8 @@ jobs: HEAD_SHA: ${{ github.sha }} REF: ${{ github.ref }} run: | - if [ "$REF" = "refs/heads/dev" ]; then - echo "dev: без фильтров, всё true" + if [ "$REF" = "refs/heads/dev" ] || [ "$REF" = "refs/heads/main" ]; then + echo "dev/main: без фильтров, всё true" node scripts/classify-changes.mjs --all >> "$GITHUB_OUTPUT" exit 0 fi @@ -713,11 +715,12 @@ jobs: git fetch -q origin dev zero=$(printf '%040d' 0) base="" - # Пуш прямо в dev: диапазон — то, что добавлено с последнего - # ДОКАЗАННО зелёного предка (#388), а не с головы предыдущего пуша: - # его прогон штатно отменяется следующим, и добавленные им строки не - # судил бы никто. Фолбэк — прежний `before`. - if [ "$EVENT_NAME" != "pull_request" ] && [ "$REF" = "refs/heads/dev" ]; then + # Пуш прямо в dev/main: диапазон — то, что добавлено с последнего + # ДОКАЗАННО зелёного предка (#388, #619), а не с головы предыдущего + # пуша: его прогон штатно отменяется следующим, и добавленные им + # строки не судил бы никто. Фолбэк — прежний `before`. + if [ "$EVENT_NAME" != "pull_request" ] \ + && { [ "$REF" = "refs/heads/dev" ] || [ "$REF" = "refs/heads/main" ]; }; then base="${PROVEN_BASE:-$BEFORE_SHA}" fi # Ветка, PR, новая история: точки отсчёта нет, берём merge-base с dev. From eecb5291d0e5c083ed28d7ae0f5d883d2f912b29 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 11:58:52 +0300 Subject: [PATCH 2/9] =?UTF-8?q?ci:=20=D0=B7=D0=B5=D1=80=D0=BA=D0=B0=D0=BB?= =?UTF-8?q?=D0=BE=20=D0=B5=D0=B6=D0=B5=D0=BD=D0=B5=D0=B4=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=BA=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=86=D0=B5=D1=81=D1=81=D0=B0=20=D0=B2=20mai?= =?UTF-8?q?n=20(#637)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: #637 User-Visible: no --- .github/workflows/process-metrics.yml | 60 +++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .github/workflows/process-metrics.yml diff --git a/.github/workflows/process-metrics.yml b/.github/workflows/process-metrics.yml new file mode 100644 index 00000000..dfbdc10c --- /dev/null +++ b/.github/workflows/process-metrics.yml @@ -0,0 +1,60 @@ +name: Метрики процесса + +# #637: еженедельный замер вместо ощущений — lead time S→S7→S8, раунды ревью, +# прогоны Validate по исходам, минуты конвейера в S4/S7. Только чтение: отчёт +# идёт в step summary и артефакт; в issue и репозиторий ничего не пишется. +# Цифры аудита 22.09 (1,57 раунда код-ревью, S7 ≈ 36 мин/раунд, 226 Validate +# за неделю) были собраны руками за час — теперь они стоят один запуск. + +on: + schedule: + # Понедельник 05:00 UTC — после ночных прогонов, до рабочего дня. + - cron: '0 5 * * 1' + workflow_dispatch: + inputs: + days: + description: "Окно в днях" + required: false + default: "7" + type: string + +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 From 0c9334491308898bb78d9074d4ce9283e64a179c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 13:21:59 +0300 Subject: [PATCH 3/9] ci: mirror process.yml in main (#621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Конвейер исполняется из ветки по умолчанию; файл обязан совпадать с dev. Зеркало dev@50e67c09: листинг docs/reviews через Git Trees API. Issue: #621 User-Visible: no --- .github/workflows/process.yml | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index 1813ba7c..3eb81d2a 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -162,15 +162,28 @@ jobs: done target="${branch:-dev}" names=$(mktemp); docs=$(mktemp -d) - if ! gh api "repos/$REPO/contents/docs/reviews?ref=$target" --jq '.[].name' \ - > "$names" 2>/dev/null; then + # #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 - # Каталог перечисляется одним ответом до 1000 записей; за этой - # границей ответ молча обрежется, и счёт по файлам занизится. - if [ "$(grep -c . "$names")" -ge 1000 ]; then - echo "::warning::в docs/reviews не меньше 1000 файлов — листинг contents обрезается, счёт по файлам ненадёжен" + echo "::warning::дерево docs/reviews на $target не получено — счёт по файлам отключён" fi # Тела нужны только своим документам этапа: их единицы. for name in $(grep -E "^${marker}-${NUM}-r[0-9]+\\.md$" "$names" || true); do From 093ea906ae9da13a104e30304d0e1fbdafe3588c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 18:26:47 +0300 Subject: [PATCH 4/9] ci: mirror process.yml in main after #635 Issue: #635 User-Visible: no --- .github/workflows/process.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index 3eb81d2a..9d9be65e 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -396,6 +396,7 @@ jobs: # #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». @@ -427,6 +428,11 @@ jobs: 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 — падение прогона, а не предупреждение: @@ -1400,6 +1406,12 @@ jobs: # забрал бы всё, что там окажется, а после 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, From 7752329d711fc43d620d2090d44ec14598b6dce3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 07:51:40 +0300 Subject: [PATCH 5/9] ci: mirror process.yml in main after #634 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Конвейер исполняется из ветки по умолчанию; файл обязан совпадать с dev. Зеркало dev@7dc75972: промпт ревьюера ссылается на docs/process/REVIEWER.md. Issue: #634 User-Visible: no --- .github/workflows/process.yml | 199 +++++++++++++--------------------- 1 file changed, 75 insertions(+), 124 deletions(-) diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index 9d9be65e..04226fae 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -978,64 +978,52 @@ jobs: ${{ 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) || '' }} - **Если цикл не первый — объём разбора по дельте, а не заново** - (PROCESS.md §2.9, issue #214). Раньше промпт был одинаковым для - всех раундов, и повторный цикл заново выводил продуктовую рамку и - перепроверял AC, которых правка не касалась: r2 по #150 стоил - полного прогона ради одной строки в тестовой фикстуре. - - Порядок для r2 и дальше: - 1. найди вердикт предыдущего раунда в комментариях issue и SHA, - на котором он получен. SHA в вердикте не назван — это находка; - 2. объяви дельту: `git diff <тот SHA>..HEAD` для кода, дифф файла - ТЗ или тела issue для spec. Дельта — предмет этого раунда; - 3. по каждой находке предыдущего раунда покажи, чем именно она - закрыта: строка кода или текста, а не заявление автора; - 4. заново проверяй только те AC, чьё доказательство дельта - задевает. Остальные наследуй; - 5. в документе обязателен раздел «Унаследовано из r»: что - принято без повторной проверки, со ссылкой на документ того - раунда и SHA, на котором вывод получен. Без этого перечня - сокращение — молчаливое доверие, а такой тихий успех уже - дважды стоил дня (#171, #207). - - Разбор остаётся ПОЛНЫМ, если дельта не локальна: ребейз на ушедший - вперёд dev (после ребейза это другой код, §7.2), смена контракта - поведения, задета новая подсистема, либо объём дельты сопоставим с - исходной задачей. Сомневаешься — разбирай полностью и скажи почему. - - Сокращается объём РАЗБОРА, а не строгость: правка по замечанию - способна сломать AC, который предыдущий раунд признал выполненным — - так появилась регрессия #102. Поэтому граница не «только находки», а - «находки плюс всё, до чего дотягивается дельта». + Правила ревью в этом промпте не повторяются (#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. AGENTS.md и PROCESS.md — процесс, классы изменений, трейлеры, - лимит циклов, формат вердикта. - 3. Тело issue #${{ github.event.issue.number }} и все комментарии. - 4. Если меняется видимое поведение — docs/USER-GUIDE.ru.md: + 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: терминология интерфейса берётся оттуда, а не изобретается. - 5. Канонический документ затронутой подсистемы: docs/SUN.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 и - указание способа доказательства. Отдельно проверь, что автор не - выдал догадку за решение: утверждение о поведении, которого нет ни - в одном документе и которое не помечено как предположение, — + указание способа доказательства. Утверждение о поведении, которого + нет ни в одном документе и которое не помечено как предположение, — замечание. Не бывает сложной задачи без единого открытого вопроса. - - Владельцу задаются только продуктовые вопросы: что человек видит или - делает и каков объём видимых изменений в этом issue. Технический - вопрос, вынесенный владельцу, — тоже замечание: ты его снимаешь и - решаешь по существу в своём вердикте. + Технический вопрос, вынесенный владельцу, — тоже замечание: ты его + снимаешь и решаешь по существу в своём вердикте. Для этапа code: материал — диапазон `git log --oneline origin/dev..HEAD` и `git diff origin/dev...HEAD`. **Материал ревью — ровно @@ -1044,95 +1032,60 @@ jobs: привязан к этому SHA (#312), и страж слияния сверяет вершину ветки с ним. Если автор в issue называет более новый коммит, которого в материале нет, — это находка «материал не был запушен до метки», а не - повод подтянуть его самому (#437 r3→r4 стоил лишнего раунда именно так, - #499). Ручного тестирования в цикле нет, + повод подтянуть его самому (#499). Ручного тестирования в цикле нет, поэтому именно ты отвечаешь на вопрос «оно вообще работает». По каждому AC: либо он доказан автотестом и ты убедился, что тест умеет падать, либо разобран по коду с явной записью «проверено - чтением, не исполнением». «Verified» без названной команды и её - результата доказательством не является. Зависимости уже установлены - workflow, Chromium тоже — `npm ci` выполнять не нужно. Проверь - трейлеры Issue и User-Visible, при User-Visible: yes — правки в оба - changelog в том же коммите. + чтением, не исполнением». Для защитного AC (валидация, гард, лимит, + отказ, инвариант) в документе обязательна строка таблицы + «AC · чем доказан · чем краснеет» с результатом прогона; пустой + третий столбец — находка Medium (§2.7, #435). «Verified» без + названной команды и её результата доказательством не является. + Проверь трейлеры Issue и User-Visible, при User-Visible: yes — правки + в оба changelog в том же коммите. Если дифф меняет величину, видимую + пользователю, назови прямо: какое число видно дважды и один ли у + него источник (§8). - **Объём гейтов соразмерен задаче.** Прогонять весь набор на каждой - правке — не тщательность, а потеря времени: полные наборы это - предрелизный гейт (PROCESS.md §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/**`: отпечаток скриншотов документации считается по всему - `src/**`, поэтому любая правка фронтенда делает его устаревшим — - выбирать тут нечего. Пропуск этого шага в #230 и #234 оставил `dev` - с красным job `docs` до следующей задачи (#237). - - Если diff трогает геометрию или ссылки на неё — рёбра комнат, - записи толщины, `layout`, `marker.space`, `open_spans` — обязательны - инварианты модели (#254): `npm test` уже гоняет их на всех моделях - проекта, а на конкретной конфигурации они проверяются командой - `npm run invariants -- --config <экспорт или ответ config/get>`. - Три вопроса, на которые они отвечают, и все три уже стоили - продукту дефектов: не исчезла ли запись толщины (#253), разрешима ли - каждая ссылка (#244, #252) и равен ли ключ записи толщины ключу - решёточного ребра (#258, #259). Последний сравнивает строки без - допусков: сдвиг ключа на один шаг решётки равен допуску первых двух, - поэтому они на нём промахиваются. Если задача меняет геометрию, а - инварианты в отчёте не названы — это непрогнанный гейт, а не мелочь. - - По необходимости, и «необходимость» определяется diff'ом и AC: - - браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс те, - что печатает `node scripts/smoke-select.mjs --base --head `. - Сколько их всего — считает `ls demo/smoke_*.mjs | wc -l`; вшитое - в этот текст число трижды расходилось с деревом, поэтому его - здесь больше нет. Прогон всех уместен только когда задача - действительно задевает всё. Выбирать по теме недостаточно: регресс #234 поймал - `smoke_wall_junctions`, который по названию про стыки стен, а не - про толщину отрезка. Инструмент печатает три вида ответа, и они - разные: «прямое совпадение» — смок называет изменённый символ, - «зарегистрированная связь» — смок проверяет следствие контракта, - не называя его, «НЕОПРЕДЕЛЁННОСТЬ» — связь не доказана, и это не - разрешение ничего не прогонять. Вывод инструмента прикладывается - к комментарию ревью вместе с решением по каждой строке: прогнал - либо не прогнал и почему. Слабые связи (одно распространённое - имя) — повод посмотреть, а не обязанность прогонять; - - `npm run golden:verify` — если diff может изменить видимый - результат: рендер, геометрия, стили, слои; - - `python -m pytest tests_backend -q` — если тронут - `custom_components/**/*.py`; - - performance-профили — если названы в AC либо тронуты - чувствительные к перфу пути. - - **Одно число — один источник.** Если дифф добавляет или меняет - величину, видимую пользователю, назови в отчёте прямо: какое число - видно дважды (превью против записи, подпись против площади, - подсветка инструмента против сохранённого значения) и один ли у него - источник. Три дефекта подряд имели именно эту причину — #234, #233 и - способ, которым #234 обнаружили. Механическая часть закреплена - тестом `test/single-source-numbers.test.mjs`, смысловая — твоя. - - Дисциплина «тест должен уметь падать» не отменяется, но применяется к - тем тестам, которые ты прогонял. + и в повторном раунде тоже: `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 - НЕ заводится (решение владельца 2026-08-19, #202: заведение и - обслуживание issue дороже правки на месте); Low либо правится, - либо снимается с записью. Жёлтый вердикт допустим и при полностью - выполненных AC, если изменение не решает заявленный сценарий или - ухудшает смежный. Продуктовое рассуждение расширяет вопросы, но не - отменяет AC и не даёт права менять скоуп. + НЕ заводится (#202); Low либо правится, либо снимается с записью. + Жёлтый вердикт допустим и при полностью выполненных AC, если + изменение не решает заявленный сценарий или ухудшает смежный. + Продуктовое рассуждение расширяет вопросы, но не отменяет AC и не + даёт права менять скоуп. Только Medium-находку ВНЕ скоупа задачи (попутный дефект соседнего поведения, который в этой ветке чинить нельзя) заведи отдельным @@ -1142,13 +1095,11 @@ jobs: Напиши полный документ ревью в файл, путь которого лежит в переменной окружения REVIEW_DOC (абсолютный, ВНЕ репозитория). - - Почему не в docs/reviews: документ там был некоммитнутым файлом того - же дерева, которое ты мутируешь, проверяя «умеет ли тест падать». На - #220 три раунда подряд документ исчезал — восстановление дерева - (`git checkout -- .`, `git clean -fd`) сносит собственный артефакт - ревью, потому что он untracked. В репозиторий его положит шаг - публикации, взяв из REVIEW_DOC; тебе трогать docs/reviews не нужно. + Не в docs/reviews: восстановление дерева после проверки «умеет ли + тест падать» (`git checkout -- .`, `git clean -fd`) сносит + untracked-файл, и на #220 документ так исчезал три раунда подряд. + В репозиторий его положит шаг публикации, взяв из REVIEW_DOC; + тебе трогать docs/reviews не нужно. В самом репозитории не создавай файлов вообще: любые изменения в рабочей копии будут отброшены. Имя документа в docs/reviews шаг From be54edf988888a02cb37b350289bed08dd10fc42 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 09:24:21 +0300 Subject: [PATCH 6/9] ci: mirror mutation-gate.yml in main after #620 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ночной реестр исполняется из ветки по умолчанию; файл обязан совпадать с dev. Зеркало dev@ee4bbf8f: пропуск ночи по неизменённому дереву, окружение шарда по плану. Issue: #620 User-Visible: no --- .github/workflows/mutation-gate.yml | 84 ++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mutation-gate.yml b/.github/workflows/mutation-gate.yml index b1a8825d..bee8fa3e 100644 --- a/.github/workflows/mutation-gate.yml +++ b/.github/workflows/mutation-gate.yml @@ -19,6 +19,13 @@ name: Мутационный гейт # 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. on: workflow_dispatch: @@ -54,6 +61,8 @@ jobs: 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: @@ -65,10 +74,48 @@ jobs: 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 }}-${{ 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 @@ -164,7 +211,7 @@ jobs: evidence: name: "Доказать единый material всех шардов" needs: [material, mutants] - if: always() + if: always() && needs.material.outputs.reuse != 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -189,6 +236,36 @@ jobs: --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 на # каждый такой отказ был бы шумом, который снова перестанут читать. @@ -198,7 +275,10 @@ jobs: report: name: "Отказ расписания: issue и Telegram" needs: [material, mutants, evidence] - if: always() && github.event_name == 'schedule' && (needs.mutants.result != 'success' || needs.evidence.result != 'success') + # #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 From 8741f3f6d7513d5f03ad14ca7a5c42b136d5d7e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 10:08:20 +0300 Subject: [PATCH 7/9] ci: mirror process.yml in main after #643 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Конвейер исполняется из ветки по умолчанию; файл обязан совпадать с dev. Зеркало dev: ребейз перед ревью пересобирает docs/reviews/INDEX.md вместо отказа. Issue: #643 User-Visible: no --- .github/workflows/process.yml | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index 04226fae..efd9d825 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -413,11 +413,27 @@ jobs: behind=$(git rev-list --count "HEAD..origin/dev") before=$(git rev-parse "origin/$BRANCH") echo "dev впереди на $behind коммит(ов) — привожу ветку" - if ! git rebase origin/dev; then - # Список снимается ДО abort: он же снимает состояние конфликта, и - # тогда автору достаётся «не ребейзится» без единого имени файла (#364). - files=$(git diff --name-only --diff-filter=U | sort -u | paste -sd'\n' -) - git rebase --abort || true + # #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< Date: Thu, 24 Sep 2026 10:59:05 +0300 Subject: [PATCH 8/9] ci: mirror thin workflow callers in main after #623 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Шесть файлов, которые GitHub исполняет из ветки по умолчанию, стали тонкими вызывающими: тела живут в dev (`_*.yml`) и вызываются `@dev`. Это последнее ручное зеркало тел; дальше тонкие файлы меняются только вместе с триггерами или потолком прав. Issue: #623 User-Visible: no --- .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 +---------------------- 6 files changed, 104 insertions(+), 2129 deletions(-) diff --git a/.github/workflows/mutation-gate.yml b/.github/workflows/mutation-gate.yml index bee8fa3e..e40bf53f 100644 --- a/.github/workflows/mutation-gate.yml +++ b/.github/workflows/mutation-gate.yml @@ -1,31 +1,12 @@ name: Мутационный гейт -# Реестр известных поломок (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). Для этого события GitHub берёт workflow из +# ветки по умолчанию (`main`), поэтому здесь только то, что обязано жить там: +# триггеры, run-name, права и concurrency. Тело — `_mutation-gate.yml` по ссылке +# `@dev`: правка конвейера — один коммит в `dev`, зеркало в `main` не нужно. +# Этот файл меняется, только когда меняются сами триггеры или потолок прав; +# тогда он зеркалится в `main`, и preflight `workflow_sync` (validate.yml) +# держит копии равными. on: workflow_dispatch: @@ -52,306 +33,15 @@ concurrency: cancel-in-progress: true 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 }}-${{ 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 < Date: Sun, 27 Sep 2026 14:20:42 +0300 Subject: [PATCH 9/9] ci: mirror thin workflow callers in main after #658 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Расписания исполняются из ветки по умолчанию: три тонких вызывающих файла обязаны совпадать с dev. Зеркало dev@f1a87fba: cron ушли с круглых минут (ночь 02:17, мутанты 00:43, метрики понедельника 05:23). Issue: #658 User-Visible: no Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd --- .github/workflows/mutation-gate.yml | 7 ++++--- .github/workflows/nightly.yml | 4 +++- .github/workflows/process-metrics.yml | 5 +++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/mutation-gate.yml b/.github/workflows/mutation-gate.yml index e40bf53f..7886eeff 100644 --- a/.github/workflows/mutation-gate.yml +++ b/.github/workflows/mutation-gate.yml @@ -16,9 +16,10 @@ on: required: false default: dev schedule: - # Каждую ночь, 01:00 UTC (04:00 MSK) — после суток правок и до ночного - # полного Validate (nightly.yml, 02:30 UTC), чтобы не делить раннеры (#513). - - cron: '0 1 * * *' + # Каждую ночь, 00:43 UTC (03:43 MSK) — после суток правок и до ночного + # полного Validate (nightly.yml, 02:17 UTC), чтобы не делить раннеры (#513). + # Минута не круглая: такие старты GitHub сдвигает меньше (#658). + - cron: '43 0 * * *' permissions: contents: read diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index af2f6caa..81a97e4c 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -9,8 +9,10 @@ name: Ночной полный прогон dev # держит копии равными. on: + # 02:17 UTC, не на круглой минуте: старты «ровно в час/полчаса» GitHub + # откладывает на часы (факт — 07:42–08:05 при плане 02:30, #658). schedule: - - cron: '30 2 * * *' + - cron: '17 2 * * *' workflow_dispatch: {} permissions: diff --git a/.github/workflows/process-metrics.yml b/.github/workflows/process-metrics.yml index 0c9d10dc..3f5004e6 100644 --- a/.github/workflows/process-metrics.yml +++ b/.github/workflows/process-metrics.yml @@ -10,8 +10,9 @@ name: Метрики процесса on: schedule: - # Понедельник 05:00 UTC — после ночных прогонов, до рабочего дня. - - cron: '0 5 * * 1' + # Понедельник 05:23 UTC — после ночных прогонов, до рабочего дня; минута + # не круглая, чтобы не стоять в очереди «ровных» расписаний (#658). + - cron: '23 5 * * 1' workflow_dispatch: inputs: days: