From 62d544611488536847112f78d3ca9e94fb7edcb1 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 12 Sep 2026 13:55:00 +0300 Subject: [PATCH 1/9] =?UTF-8?q?ci:=20mirror=20process.yml=20from=20dev=20(?= =?UTF-8?q?#539=20=E2=80=94=20=D0=B4=D0=B8=D1=81=D0=BF=D0=B0=D1=82=D1=87?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D1=82=D0=BE=D0=B3=D0=BE,?= =?UTF-8?q?=20=D0=BA=D0=B0=D0=BA=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B5=D1=85=D0=B0=D0=BB=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Для события `issues` GitHub берёт workflow из ветки по умолчанию, поэтому правка конвейера действует только из `main`. Issue: #539 User-Visible: no --- .github/workflows/process.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index 6d2287c3..a9384849 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -347,6 +347,8 @@ jobs: 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 }} # rebase, в отличие от commit, не принимает -c user.*: он запускает # свои процессы и требует личность в окружении, иначе падает с @@ -389,6 +391,25 @@ jobs: 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" From 63816a4ec33689f6db76ee87ad6dd69a88d506bf Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 12 Sep 2026 14:37:55 +0300 Subject: [PATCH 2/9] =?UTF-8?q?ci:=20mirror=20announce.yml=20and=20release?= =?UTF-8?q?.yml=20from=20dev=20(#538=20=E2=80=94=20=D0=B0=D0=BD=D0=BE?= =?UTF-8?q?=D0=BD=D1=81=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Оба файла живут на `main`: релизные воркфлоу запускаются событием релиза, а оно берёт их из ветки по умолчанию. Без зеркала анонс на `main` продолжал бы висеть на самом событии. Issue: #538 User-Visible: no --- .github/workflows/announce.yml | 39 ++++++++++++++-------------------- .github/workflows/release.yml | 17 +++++++++++++++ 2 files changed, 33 insertions(+), 23 deletions(-) diff --git a/.github/workflows/announce.yml b/.github/workflows/announce.yml index 08327464..29ff1de0 100644 --- a/.github/workflows/announce.yml +++ b/.github/workflows/announce.yml @@ -3,9 +3,14 @@ name: Анонс релиза # Stable releases are announced; prereleases are deliberately silent. # workflow_dispatch exists purely as a connectivity test button and therefore # remains allowed to send a test message. +# #538: НЕТ триггера `release: published`. Три воркфлоу висели на одном событии +# и бежали параллельно, а анонсу нечего было проверять — он выигрывал гонку +# всегда. 12.09 v1.75.0 объявили в канале в ту же минуту, когда гейт ассетов +# отказал: релиз остался без `houseplan-card.js`, E2E не выполнялся вовсе, а +# подписчики получили сообщение. Анонс вызывается только после того, как работа +# сделана: `release.yml` зовёт его после выкладки ассетов, +# `publish-prerelease.yml` — после публикации беты. on: - release: - types: [published] workflow_dispatch: {} workflow_call: inputs: @@ -37,7 +42,7 @@ permissions: jobs: telegram: name: Оповещение в Telegram (только стабильные) - if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && github.event.release.prerelease == false) || (github.event_name == 'workflow_call' && inputs.prerelease == false) }} + if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_call' && inputs.prerelease == false) }} runs-on: ubuntu-latest steps: - name: Check out release notes for a reusable call @@ -54,32 +59,20 @@ jobs: INPUT_NAME: ${{ inputs.release_name }} INPUT_URL: ${{ inputs.url }} INPUT_PRE: ${{ inputs.prerelease }} - RELEASE_TAG: ${{ github.event.release.tag_name }} - RELEASE_NAME: ${{ github.event.release.name }} - RELEASE_URL: ${{ github.event.release.html_url }} - RELEASE_PRE: ${{ github.event.release.prerelease }} - # The body goes through env, never through shell interpolation — - # release notes are arbitrary text. - RELEASE_BODY: ${{ github.event.release.body }} EVENT: ${{ github.event_name }} run: | set -euo pipefail if [ "$EVENT" = "workflow_dispatch" ] && [ "$CALLED" != "true" ]; then TEXT="✅ Тест: оповещения о релизах houseplan-card подключены." else - if [ "$CALLED" = "true" ]; then - TAG=$INPUT_TAG - NAME=$INPUT_NAME - URL=$INPUT_URL - PRE=$INPUT_PRE - BODY=$(cat docs/RELEASE-NOTES.md) - else - TAG=$RELEASE_TAG - NAME=$RELEASE_NAME - URL=$RELEASE_URL - PRE=$RELEASE_PRE - BODY=$RELEASE_BODY - fi + # Единственный путь к сообщению о релизе — вызов из воркфлоу, который + # уже закончил работу (#538). Тело берётся из файла заметок ветки + # тега, а не из события: события здесь больше нет. + TAG=$INPUT_TAG + NAME=$INPUT_NAME + URL=$INPUT_URL + PRE=$INPUT_PRE + BODY=$(cat docs/RELEASE-NOTES.md) if [ "$PRE" = "true" ]; then echo "Prerelease Telegram announcement is disabled" exit 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c31f6d2a..6aa9b067 100755 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,6 +96,23 @@ jobs: uses: softprops/action-gh-release@v3 with: files: dist/houseplan-card.js + announce: + # #538: анонс — последнее звено, а не параллельное. Пока он висел на самом + # событии `release: published`, он обгонял гейт: 12.09 v1.75.0 объявили в + # канале в ту же минуту, когда проверка отказала выкладывать ассеты. + # `needs: build` означает, что молчание — это тоже ответ: красный гейт или + # несостоявшаяся выкладка сообщения не рождают. + name: Оповещение о релизе после выкладки + needs: build + uses: ./.github/workflows/announce.yml + with: + reusable: true + tag: ${{ github.event.release.tag_name }} + release_name: ${{ github.event.release.name }} + url: ${{ github.event.release.html_url }} + prerelease: ${{ github.event.release.prerelease }} + ref: ${{ github.event.release.tag_name }} + secrets: inherit hacs-discovery: name: HACS-видимость пре-релиза (порядок бет) # HACS 2.0.x takes the first prerelease in GitHub's response instead of From 48b9649d4595c970fcc4d79ad36e0774d2f9f12f Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sun, 13 Sep 2026 11:46:19 +0300 Subject: [PATCH 3/9] =?UTF-8?q?ci:=20=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?mutation-gate.yml=20=D1=81=20dev=20(#549)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: #549 User-Visible: no --- .github/workflows/mutation-gate.yml | 93 ++++++++++++++++++++++++----- 1 file changed, 78 insertions(+), 15 deletions(-) diff --git a/.github/workflows/mutation-gate.yml b/.github/workflows/mutation-gate.yml index 013c48a2..bf15a931 100644 --- a/.github/workflows/mutation-gate.yml +++ b/.github/workflows/mutation-gate.yml @@ -43,8 +43,30 @@ 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 }} + steps: + - uses: actions/checkout@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" + mutants: name: "Мутанты: каждый обязан красить тесты (шард ${{ matrix.shard }} из 4)" + needs: material runs-on: ubuntu-latest strategy: fail-fast: false @@ -56,7 +78,7 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || 'dev' }} + ref: ${{ needs.material.outputs.sha }} fetch-depth: 0 - uses: actions/setup-node@v7 @@ -101,18 +123,56 @@ jobs: # `tee` не съел код выхода раннера. - name: Каждый тест ловит свою поломку run: | - mkdir -p artifacts + mkdir -p artifacts/mutation-shard-${{ matrix.shard }} set -o pipefail - node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}.log - - name: Сохранить лог шарда + node scripts/mutation-gate.mjs --shard=${{ matrix.shard }}/4 2>&1 | tee artifacts/mutation-shard-${{ matrix.shard }}/mutation-shard-${{ matrix.shard }}.log + - 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.sha }} \ + --run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \ + --shard=${{ matrix.shard }} --shards=4 + - name: Сохранить лог и identity шарда if: always() uses: actions/upload-artifact@v7 with: - name: mutation-shard-${{ matrix.shard }} - path: artifacts/mutation-shard-${{ matrix.shard }}.log + name: mutation-shard-${{ matrix.shard }}-attempt-${{ github.run_attempt }} + path: artifacts/mutation-shard-${{ matrix.shard }} if-no-files-found: warn retention-days: 30 + # Результат нельзя приписывать material, пока не доказаны все четыре шарда. + # always() нужен при красном мутанте: лог красного шарда всё равно evidence. + evidence: + name: "Доказать единый material всех шардов" + needs: [material, mutants] + if: always() + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.sha }} + - uses: actions/setup-node@v7 + with: + node-version: 22 + - name: Забрать evidence всех попыток + uses: actions/download-artifact@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=4 \ + --sha=${{ needs.material.outputs.sha }} \ + --tree=${{ needs.material.outputs.tree }} \ + --workflow-sha=${{ github.sha }} \ + --run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} + # Адресат у отказа (#472). Только по расписанию: ручной dispatch остаётся # для отладки самого гейта, его результат смотрят в прогоне — issue на # каждый такой отказ был бы шумом, который снова перестанут читать. @@ -121,8 +181,8 @@ jobs: # validate.yml, job с actions: read): перечислены все три. report: name: "Отказ расписания: issue и Telegram" - needs: mutants - if: always() && github.event_name == 'schedule' && needs.mutants.result != 'success' + needs: [material, mutants, evidence] + if: always() && github.event_name == 'schedule' && (needs.mutants.result != 'success' || needs.evidence.result != 'success') runs-on: ubuntu-latest permissions: contents: read @@ -131,11 +191,14 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: dev + # Код отчётчика берётся из того же workflow revision, а не из + # успевшего сдвинуться dev. Проверяемый material передаётся отдельно. + ref: ${{ github.sha }} - uses: actions/setup-node@v7 with: node-version: 22 - name: Забрать логи шардов + continue-on-error: true uses: actions/download-artifact@v7 with: pattern: mutation-shard-* @@ -145,14 +208,14 @@ jobs: env: RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | - # SHA — того дерева, которое чекаутили и гоняли (dev), а не - # github.sha: для расписания это вершина default-ветки main, и отчёт - # называл бы «dev @ » (ревью r1). Образец — process.yml. - SHA=$(git rev-parse HEAD) mkdir -p artifacts node scripts/mutation-gate-report.mjs \ - --logs=artifacts/mutation-logs --shards=4 \ - --run-url="$RUN_URL" --ref=dev --sha="$SHA" \ + --require-evidence --logs=artifacts/mutation-logs --shards=4 \ + --sha=${{ needs.material.outputs.sha }} \ + --tree=${{ needs.material.outputs.tree }} \ + --workflow-sha=${{ github.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, а не одно на неделю: открытое с тем же маркером в заголовке From 5bda3d4393cc8900d9b3f1dd3ab692a91f1c3ac2 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sun, 13 Sep 2026 12:18:57 +0300 Subject: [PATCH 4/9] =?UTF-8?q?ci:=20=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20mutation-gate.yml=20=D1=81=20dev=20(#549)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: #549 User-Visible: no --- .github/workflows/mutation-gate.yml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/mutation-gate.yml b/.github/workflows/mutation-gate.yml index bf15a931..ce66d331 100644 --- a/.github/workflows/mutation-gate.yml +++ b/.github/workflows/mutation-gate.yml @@ -133,7 +133,7 @@ jobs: --write-evidence=artifacts/mutation-shard-${{ matrix.shard }}/evidence.json \ --sha=${{ needs.material.outputs.sha }} \ --tree=${{ needs.material.outputs.tree }} \ - --workflow-sha=${{ github.sha }} \ + --workflow-sha=${{ github.workflow_sha }} \ --run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} \ --shard=${{ matrix.shard }} --shards=4 - name: Сохранить лог и identity шарда @@ -155,7 +155,9 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: ${{ github.sha }} + # CLI отчётчика принадлежит зафиксированному material. В main лежит + # только workflow и может не быть dev-версии scripts/**. + ref: ${{ needs.material.outputs.sha }} - uses: actions/setup-node@v7 with: node-version: 22 @@ -170,7 +172,7 @@ jobs: --logs=artifacts/mutation-logs --shards=4 \ --sha=${{ needs.material.outputs.sha }} \ --tree=${{ needs.material.outputs.tree }} \ - --workflow-sha=${{ github.sha }} \ + --workflow-sha=${{ github.workflow_sha }} \ --run-id=${{ github.run_id }} --run-attempt=${{ github.run_attempt }} # Адресат у отказа (#472). Только по расписанию: ручной dispatch остаётся @@ -191,9 +193,9 @@ jobs: steps: - uses: actions/checkout@v7 with: - # Код отчётчика берётся из того же workflow revision, а не из - # успевшего сдвинуться dev. Проверяемый material передаётся отдельно. - ref: ${{ github.sha }} + # Не перечитываем moving dev: код отчётчика берётся из уже + # зафиксированного material. В стабильном main dev-скриптов нет. + ref: ${{ needs.material.outputs.sha }} - uses: actions/setup-node@v7 with: node-version: 22 @@ -213,7 +215,7 @@ jobs: --require-evidence --logs=artifacts/mutation-logs --shards=4 \ --sha=${{ needs.material.outputs.sha }} \ --tree=${{ needs.material.outputs.tree }} \ - --workflow-sha=${{ github.sha }} \ + --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 \ From 7b69726217b7cd99e370521d423d09573ca7b070 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sun, 13 Sep 2026 13:48:07 +0300 Subject: [PATCH 5/9] =?UTF-8?q?ci:=20=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=80=D0=B0=D0=B7=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D1=81=D1=82=D0=B0=D0=B4?= =?UTF-8?q?=D0=B8=D0=B8=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20=D0=B2=20main?= =?UTF-8?q?=20(#551)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: #551 User-Visible: no --- .github/workflows/process.yml | 506 +++++++++++++++++++++++++++------- 1 file changed, 414 insertions(+), 92 deletions(-) diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index a9384849..37f550cc 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -254,17 +254,41 @@ jobs: echo "spent=$spent" >> "$GITHUB_OUTPUT" echo "limit=$limit" >> "$GITHUB_OUTPUT" - review: - name: "Ревью (Claude): вердикт в issue" + prepare: + name: "Ревью: материал и deterministic gates" needs: guard if: needs.guard.outputs.stage != '' runs-on: ubuntu-latest concurrency: group: process-issue-${{ github.event.issue.number }} cancel-in-progress: false - # Время — единственный настоящий ограничитель зациклившегося прогона. - timeout-minutes: 45 + # Ожидание 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@v7 with: fetch-depth: 0 @@ -620,66 +644,13 @@ jobs: } >> "$GITHUB_OUTPUT" if [ -n "$row" ]; then echo "Validate на $short: зелёный"; else echo "Validate на $short: зелёного нет"; fi - # Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог - # измениться именно в ней, и установка по копии из dev дала бы не то дерево. - - name: Установить зависимости - if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' - run: npm ci - - # Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте), - # но когда нужен — качать его заново дороже, чем держать в кэше. - - name: Кэш браузеров Playwright - id: pw - if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' - uses: actions/cache@v6 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} - - - name: Установить Chromium - if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' && 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 - if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' - run: | - src=$(ls "$RUNNER_WORKSPACE"/../_actions/anthropics/claude-code-*/v1/src/entrypoints/run.ts 2>/dev/null | head -1) - ver=$(grep -oE 'claudeCodeVersion = "[0-9]+\.[0-9]+\.[0-9]+"' "$src" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || true) - ver="${ver:-2.1.265}" - base=https://downloads.claude.ai/claude-code-releases - bin="$HOME/.local/bin/claude" - mkdir -p "$(dirname "$bin")" - curl -fsSL --retry 3 "$base/$ver/linux-x64/claude" -o "$bin" - sum=$(curl -fsSL --retry 3 "$base/$ver/manifest.json" | jq -r '.platforms["linux-x64"].checksum') - echo "$sum $bin" | sha256sum -c - - chmod +x "$bin" - "$bin" --version - echo "path=$bin" >> "$GITHUB_OUTPUT" - - # ТЗ живёт в теле issue (#517), а GitHub хранит его правки без diff и без - # доступной чтением истории. Единственное, что можно доказать, — «текст - # изменился после того, как ревью ТЗ признало его зелёным». Это не отказ: - # автор вправе уточнять ТЗ. Это находка, доставляемая ревьюеру кода в - # промпт и владельцу в комментарий вердикта: разбор AC в таком заходе - # обязан быть полным, а не по дельте. + # Это 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 }} - BRANCH: ${{ steps.branch.outputs.name }} DIGEST: ${{ steps.material.outputs.issue_body }} run: | if [ -z "$DIGEST" ]; then @@ -702,9 +673,178 @@ jobs: 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@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: "Ревью: работа модели" + 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@v7 + with: + fetch-depth: 0 + ref: ${{ needs.prepare.outputs.material_sha }} + persist-credentials: false + + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: npm + + - name: Получить контракт подготовленного материала + uses: actions/download-artifact@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@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 - if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' uses: anthropics/claude-code-action@v1 env: # Вне рабочей копии: восстановление дерева ревьюером не должно @@ -729,9 +869,9 @@ jobs: Номер захода нужен для имени документа — два документа с одинаковым номером затёрли бы друг друга. - ${{ steps.rebase.outputs.note }} + ${{ needs.prepare.outputs.rebase_note }} - ${{ steps.spec_body.outputs.changed == 'true' && format('ТЗ в теле issue менялось после зелёного ревью ТЗ ({0}, записанный хеш {1}). GitHub хранит правки тела без diff — дельту показать нельзя, поэтому AC сверяются с ТЕКУЩИМ текстом целиком, а не по дельте, и находка называется в вердикте (#517).', steps.spec_body.outputs.doc, steps.spec_body.outputs.recorded) || '' }} + ${{ 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). Раньше промпт был одинаковым для @@ -794,7 +934,7 @@ jobs: Для этапа code: материал — диапазон `git log --oneline origin/dev..HEAD` и `git diff origin/dev...HEAD`. **Материал ревью — ровно - `${{ steps.material.outputs.sha }}`, рабочая копия уже на нём.** Не + `${{ needs.prepare.outputs.material_sha }}`, рабочая копия уже на нём.** Не делай `git fetch`, `git pull` и `git checkout` на другой коммит: вердикт привязан к этому SHA (#312), и страж слияния сверяет вершину ветки с ним. Если автор в issue называет более новый коммит, которого в @@ -814,7 +954,7 @@ jobs: правке — не тщательность, а потеря времени: полные наборы это предрелизный гейт (PROCESS.md §8), а не гейт ревью. - ${{ steps.validated.outputs.note }} + ${{ needs.prepare.outputs.validated_note }} Если зелёного прогона на этом SHA нет — прогоняешь сам, они дешёвые, и в повторном раунде тоже: код изменился, а стоят они минуты: @@ -928,25 +1068,174 @@ jobs: --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"]}' - # Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит, - # решает этот шаг, а не модель: всё остальное откатывается. - - name: Опубликовать документ ревью - if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' + - name: Запечатать результат модели + id: result env: - TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} - BRANCH: ${{ steps.branch.outputs.name }} + 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 }} - SOURCE: ${{ runner.temp }}/review-document.md + 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" | 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" + 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@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: "Ревью: публикация и интеграция" + 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" != "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@v7 + if: steps.ready.outputs.proceed == 'true' + with: + fetch-depth: 0 + ref: dev + persist-credentials: false + + - uses: actions/setup-node@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@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' + 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-result" + expected=$(printf '%s\n' manifest.sha256 prepared.json review-document.md verdict.json | sort) + actual=$(find "$dir" -maxdepth 1 -type f -printf '%f\n' | sort) + test "$actual" = "$expected" || { echo "::error::неполный или неожиданный набор evidence"; printf 'ожидалось:\n%s\nполучено:\n%s\n' "$expected" "$actual"; exit 1; } + (cd "$dir" && sha256sum -c manifest.sha256) + test -s "$dir/review-document.md" + 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" + jq -e ' + (.verdict == "green" or .verdict == "yellow" or .verdict == "red") + and (.high | type == "number") and (.medium | type == "number") + and (.summary | type == "string")' "$dir/verdict.json" >/dev/null + { + echo 'structured_output<> "$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: ${{ steps.material.outputs.sha }} - MATERIAL_TREE: ${{ steps.material.outputs.tree }} - MATERIAL_SPECS: ${{ steps.material.outputs.specs }} - MATERIAL_ISSUE_BODY: ${{ steps.material.outputs.issue_body }} + 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.review.outputs.structured_output }} + 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) @@ -1096,12 +1385,12 @@ jobs: # достижим там из необновлённой локальной ветки. Читателю отчёта от этого # пользы нет — он достанет только то, что есть на origin. - name: "Материал раунда воспроизводим (#413)" - if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' + 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: ${{ steps.branch.outputs.name }} + BRANCH: ${{ needs.prepare.outputs.branch }} run: | marker=CODE-REVIEW if [ "$STAGE" = "spec" ]; then marker=SPEC-REVIEW; fi @@ -1113,14 +1402,14 @@ jobs: git show "origin/$target:$doc" | node scripts/review-doc-guard.mjs --doc=- - name: Решение по вердикту id: decide - if: steps.gate.outputs.proceed == 'true' + if: steps.ready.outputs.proceed == 'true' env: - OUT: ${{ steps.review.outputs.structured_output }} + OUT: ${{ steps.result.outputs.structured_output }} STAGE: ${{ needs.guard.outputs.stage }} - REUSE: ${{ steps.reuse.outputs.reuse }} - REUSE_DOC: ${{ steps.reuse.outputs.doc }} - REUSE_ROUND: ${{ steps.reuse.outputs.round }} - REUSE_TREE: ${{ steps.reuse.outputs.tree }} + 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: | @@ -1168,11 +1457,11 @@ jobs: # dev действительно ушёл и вердикт зелёный, то есть слияние вот-вот # случится (#364). - name: dev ушёл вперёд, пока шло ревью - if: steps.gate.outputs.proceed == 'true' && needs.guard.outputs.stage == 'code' + if: steps.ready.outputs.proceed == 'true' && needs.guard.outputs.stage == 'code' env: GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} NUM: ${{ github.event.issue.number }} - MATERIAL: ${{ steps.material.outputs.sha }} + MATERIAL: ${{ needs.prepare.outputs.material_sha }} GREEN: ${{ steps.decide.outputs.green }} run: | git fetch -q origin dev @@ -1201,9 +1490,9 @@ jobs: env: HP_PROCESS_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} - BRANCH: ${{ steps.branch.outputs.name }} + BRANCH: ${{ needs.prepare.outputs.branch }} NUM: ${{ github.event.issue.number }} - MATERIAL_SHA: ${{ steps.material.outputs.sha }} + MATERIAL_SHA: ${{ needs.prepare.outputs.material_sha }} run: | if [ -z "$BRANCH" ]; then echo "::error::ветки задачи нет — сливать нечего" @@ -1214,7 +1503,7 @@ jobs: --issue="$NUM" --repo="${{ github.repository }}" - name: Переставить метку - if: steps.gate.outputs.proceed == 'true' + if: steps.ready.outputs.proceed == 'true' env: # Именно PAT: с GITHUB_TOKEN следующий шаг конвейера не запустится. GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} @@ -1231,18 +1520,51 @@ jobs: --add-label "$TO" --remove-label "$FROM" echo "$FROM -> $TO" - - name: Позвать владельца, если ревью упало + - 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, 13 Sep 2026 14:25:02 +0300 Subject: [PATCH 6/9] =?UTF-8?q?ci:=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=D0=B0=D1=87?= =?UTF-8?q?=D1=83=20=D0=B2=D0=B5=D1=80=D0=B4=D0=B8=D0=BA=D1=82=D0=B0=20?= =?UTF-8?q?=D0=BC=D0=B5=D0=B6=D0=B4=D1=83=20=D1=81=D1=82=D0=B0=D0=B4=D0=B8?= =?UTF-8?q?=D1=8F=D0=BC=D0=B8=20=D0=B2=20main=20(#551)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: #551 User-Visible: no --- .github/workflows/process.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index 37f550cc..3be57742 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -1082,10 +1082,11 @@ jobs: 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" | jq -e ' + 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" + 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" From 06cec9669c5b3ae1727321db566b7651df91ba68 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sun, 13 Sep 2026 14:36:11 +0300 Subject: [PATCH 7/9] =?UTF-8?q?ci:=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D0=BE=20=D0=B2=D0=B5=D1=80=D0=BD=D1=83=D1=82=D1=8C=20pro?= =?UTF-8?q?cess.yml=20main=20=D0=BA=20dev=20=D0=B4=D0=BB=D1=8F=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20(#551)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exact-SHA Validate ветки должен пройти канонический preflight до активации исправленного workflow. Issue: #551 User-Visible: no --- .github/workflows/process.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index 3be57742..37f550cc 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -1082,11 +1082,10 @@ jobs: 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 ' + printf '%s' "$OUT" | 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 + and (.summary | type == "string")' > "$RUNNER_TEMP/verdict.json" dir="$RUNNER_TEMP/review-result" mkdir -p "$dir" cp "$RUNNER_TEMP/review-prepared/prepared.json" "$dir/prepared.json" From be4b54e4d1aa84a7a1ac2fa0a2c71c23f8b931a2 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sun, 13 Sep 2026 14:45:17 +0300 Subject: [PATCH 8/9] =?UTF-8?q?ci:=20=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BA=D0=BE=D1=80=D1=80?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=BD=D1=8B=D0=B9=20output=20=D0=B8=D0=BD?= =?UTF-8?q?=D1=82=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D1=8C=D1=8E=20(#551)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workflow сохраняет объект вердикта и отделяет delimiter GitHub output завершающим LF. Issue: #551 User-Visible: no --- .github/workflows/process.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index 37f550cc..ddb522a8 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -1082,10 +1082,11 @@ jobs: 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" | jq -e ' + 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" + 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" @@ -1211,11 +1212,11 @@ jobs: (.verdict == "green" or .verdict == "yellow" or .verdict == "red") and (.high | type == "number") and (.medium | type == "number") and (.summary | type == "string")' "$dir/verdict.json" >/dev/null - { - echo 'structured_output<> "$GITHUB_OUTPUT" + printf 'structured_output<> "$GITHUB_OUTPUT" + cat "$dir/verdict.json" >> "$GITHUB_OUTPUT" + # structured_output не обязан оканчиваться LF: delimiter команды + # GitHub должен начинаться с отдельной строки. + printf '\nEOF_RESULT\n' >> "$GITHUB_OUTPUT" # Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит, # решает этот шаг, а не модель: всё остальное откатывается. From c3a749949f90ee4f5f47fd45945b4880b95c01fb Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sun, 13 Sep 2026 16:25:45 +0300 Subject: [PATCH 9/9] =?UTF-8?q?ci:=20=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20reconciler=20#555=20?= =?UTF-8?q?=D0=B2=20main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Синхронизировать исполняемый process workflow с dev и добавить bounded queue reconciler в ветку по умолчанию. Product source, release candidate и version files не меняются. Issue: #555 User-Visible: no --- .github/workflows/process-reconcile.yml | 58 +++++++++++++++++++++++++ .github/workflows/process.yml | 1 + 2 files changed, 59 insertions(+) create mode 100644 .github/workflows/process-reconcile.yml diff --git a/.github/workflows/process-reconcile.yml b/.github/workflows/process-reconcile.yml new file mode 100644 index 00000000..d62e998a --- /dev/null +++ b/.github/workflows/process-reconcile.yml @@ -0,0 +1,58 @@ +name: Сверка очереди ревью +run-name: "reconcile process queue · ${{ github.event_name }}" + +on: + schedule: + - cron: '7,37 * * * *' + workflow_dispatch: + inputs: + apply: + description: "Повторно будить потерянные запросы и публиковать диагностику" + required: true + type: boolean + default: true + +permissions: + actions: read + contents: read + issues: read + +jobs: + reconcile: + name: "Один снимок S4/S7 без polling модели" + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: process-reconcile + cancel-in-progress: false + steps: + # Расписание читается из main, а исполняемый reconciler — из dev: так + # после штатного merge действует та же версия кода, которую проверил CI. + - uses: actions/checkout@v7 + with: + ref: dev + fetch-depth: 1 + persist-credentials: false + - uses: actions/setup-node@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@v4 + with: + name: process-reconcile-${{ github.run_id }}-${{ github.run_attempt }} + path: artifacts/process-reconcile/summary.json + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index ddb522a8..6ca0440d 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -1,4 +1,5 @@ name: Ревью-конвейер +run-name: "process #${{ github.event.issue.number }} · ${{ github.event.label.name }} · ${{ github.event.issue.title }}" # Событийный конвейер процесса (PROCESS.md). Смена статусной метки — это # сообщение: она порождает событие, событие запускает следующий шаг.