name: Process # Событийный конвейер процесса (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`. on: issues: types: [labeled] concurrency: # Два события по одному issue не должны запускать два прогона. group: process-issue-${{ github.event.issue.number }} cancel-in-progress: false permissions: contents: read issues: write # Обязательно: claude-code-action получает OIDC-токен для авторизации # GitHub App. Без этого прогон падает с «Could not fetch an OIDC token». id-token: write jobs: guard: runs-on: ubuntu-latest outputs: stage: ${{ steps.decide.outputs.stage }} cycle: ${{ steps.decide.outputs.cycle }} limit: ${{ steps.decide.outputs.limit }} steps: - id: decide env: GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} LABEL: ${{ github.event.label.name }} BLOCKED: ${{ contains(github.event.issue.labels.*.name, 'blocked') }} EXHAUSTED: ${{ contains(github.event.issue.labels.*.name, 'review-4') }} SMALL: ${{ contains(github.event.issue.labels.*.name, 'small') }} TRIVIAL: ${{ contains(github.event.issue.labels.*.name, 'trivial') }} NUM: ${{ github.event.issue.number }} run: | # Этап определяется первым: от него зависит, какие вердикты считать. stage=""; marker="" case "$LABEL" in S4-spec-review) stage="spec"; marker="SPEC-REVIEW" ;; S7-code-review) stage="code"; marker="CODE-REVIEW" ;; *) echo "метка $LABEL конвейер не запускает" ;; esac # Лимит циклов: 4 обычный, 2 на лёгком и коротком треке (PROCESS.md §4). limit=4 if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi # Счётчик считает вердикты ТОЛЬКО своего этапа. Раньше он брал все # подряд, и вердикт по ТЗ съедал цикл из бюджета код-ревью: на #89 # первое код-ревью получило r2/4. На задаче с двумя циклами ТЗ второе # код-ревью упиралось бы в review-4 после одной правки. # # Этап опознаётся по имени документа в теле комментария. Если документа # нет, вердикт не посчитается — недосчёт даёт лишний цикл, а перерасчёт # остановил бы работу досрочно; из двух ошибок выбрана обратимая. done_cycles=0 if [ -n "$stage" ]; then done_cycles=$(gh issue view "$NUM" --repo "${{ github.repository }}" \ --json comments \ -q "[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))] | length") 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 refuse "стоит review-4 — решение за владельцем" \ "на issue стоит \`review-4\`: лимит циклов ревью исчерпан, дальше решает владелец — разделить задачу, отклонить или арбитраж (PROCESS.md §4)." elif [ "$done_cycles" -ge "$limit" ]; then echo "циклов этапа $stage пройдено $done_cycles из $limit — лимит исчерпан" gh issue edit "$NUM" --repo "${{ github.repository }}" --add-label review-4 gh issue comment "$NUM" --repo "${{ github.repository }}" --body \ "Лимит циклов ревью исчерпан ($done_cycles из $limit на этапе \`$stage\`). Пятого захода нет: решение владельца — разделить задачу, отклонить или арбитраж (PROCESS.md §4)." stage="" else echo "этап $stage, цикл $((done_cycles + 1)) из $limit" fi echo "stage=$stage" >> "$GITHUB_OUTPUT" echo "cycle=$((done_cycles + 1))" >> "$GITHUB_OUTPUT" echo "limit=$limit" >> "$GITHUB_OUTPUT" review: needs: guard if: needs.guard.outputs.stage != '' runs-on: ubuntu-latest # Время — единственный настоящий ограничитель зациклившегося прогона. timeout-minutes: 45 steps: - uses: actions/checkout@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@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" else echo "::warning::ветка issue/${NUM}-* не найдена на origin — ревью пойдёт по dev" echo "МАТЕРИАЛ НЕ ЗАПУШЕН" >> "$GITHUB_STEP_SUMMARY" fi # Зависимости ставятся ПОСЛЕ переключения на ветку задачи: 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 - name: Review id: review uses: anthropics/claude-code-action@v1 with: # Подписка, а не отдельный счёт API: токен выпускается через # `claude setup-token` (Pro/Max). Действуют лимиты подписки. claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} prompt: | Ты ревьюер проекта House Plan. Язык ответа — русский. Issue: #${{ github.event.issue.number }} Репозиторий: ${{ github.repository }} Этап: ${{ needs.guard.outputs.stage }} spec — ревью ТЗ (PROCESS.md §2.4) code — код-ревью (PROCESS.md §2.7) Прочитай в этом порядке, прежде чем судить: 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: терминология интерфейса берётся оттуда, а не изобретается. 5. Канонический документ затронутой подсистемы: docs/SUN.md, LIGHT.md, CANVAS.md, WALL-THICKNESS.md, UX-MODES.md, CONFIG-COMPATIBILITY.md, TOUCH-SUPPORT.md. Для этапа spec: если issue помечен small, ТЗ живёт в теле issue и файла в docs/specs/ быть не должно. Иначе ТЗ — docs/specs/-*.md. Проверь обязательные разделы §7.1, однозначность каждого AC и указание способа доказательства. Отдельно проверь, что автор не выдал догадку за решение: утверждение о поведении, которого нет ни в одном документе и которое не помечено как предположение, — замечание. Не бывает сложной задачи без единого открытого вопроса. Владельцу задаются только продуктовые вопросы: что человек видит или делает и каков объём видимых изменений в этом issue. Технический вопрос, вынесенный владельцу, — тоже замечание: ты его снимаешь и решаешь по существу в своём вердикте. Для этапа code: материал — диапазон `git log --oneline origin/dev..HEAD` и `git diff origin/dev...HEAD`. Ручного тестирования в цикле нет, поэтому именно ты отвечаешь на вопрос «оно вообще работает». По каждому AC: либо он доказан автотестом и ты убедился, что тест умеет падать, либо разобран по коду с явной записью «проверено чтением, не исполнением». «Verified» без названной команды и её результата доказательством не является. Зависимости уже установлены workflow, Chromium тоже — `npm ci` выполнять не нужно. Проверь трейлеры Issue и User-Visible, при User-Visible: yes — правки в оба changelog в том же коммите. **Объём гейтов соразмерен задаче.** Прогонять весь набор на каждой правке — не тщательность, а потеря времени: полные наборы это предрелизный гейт (PROCESS.md §8), а не гейт ревью. Всегда, они дешёвые: `npx tsc --noEmit`, `npm test`, `npm run build` со сверкой трёх копий бандла. По необходимости, и «необходимость» определяется diff'ом и AC: - браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс относящиеся к тронутым поверхностям. Их 127; прогон всех уместен только когда задача действительно задевает всё; - `npm run golden:verify` — если diff может изменить видимый результат: рендер, геометрия, стили, слои; - `python -m pytest tests_backend -q` — если тронут `custom_components/**/*.py`; - performance-профили — если названы в AC либо тронуты чувствительные к перфу пути. Дисциплина «тест должен уметь падать» не отменяется, но применяется к тем тестам, которые ты прогонял. **В комментарии обязателен перечень: какие гейты прогнал, какие нет и почему.** Это условие честности такого сужения: непрогнанный гейт становится видимым решением, а не молчаливым пропуском. Раздел «чего не проверял» в документе ревью — не формальность, а главный его раздел на коротких задачах. Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь. Серьёзность: High блокирует; Medium В СКОУПЕ задачи чинится в ней же — без High это жёлтый вердикт и возврат автору, отдельный issue НЕ заводится (решение владельца 2026-08-19, #202: заведение и обслуживание issue дороже правки на месте); Low либо правится, либо снимается с записью. Жёлтый вердикт допустим и при полностью выполненных AC, если изменение не решает заявленный сценарий или ухудшает смежный. Продуктовое рассуждение расширяет вопросы, но не отменяет AC и не даёт права менять скоуп. Только Medium-находку ВНЕ скоупа задачи (попутный дефект соседнего поведения, который в этой ветке чинить нельзя) заведи отдельным issue со ссылкой на #${{ github.event.issue.number }} и метками: тип, приоритет, S1-new. «Оставили в тексте ревью» закрытием не считается и прямо запрещено §12. Напиши полный документ ревью в файл docs/reviews/-REVIEW-${{ github.event.issue.number }}-r${{ needs.guard.outputs.cycle }}.md (SPEC для этапа spec, CODE для code): скоуп, как проверялось, находки с воспроизведением, что проверено и корректно, чего не проверял. Каталог docs/reviews/ создай, если его нет. Больше не пиши ничего: любой файл вне docs/reviews/ опубликован не будет. Затем оставь в issue краткий комментарий: вердикт, ключевые находки и ссылка на документ. Первой строкой — вердикт в формате §7.2: `Вердикт: зелёный/жёлтый/красный · цикл r${{ needs.guard.outputs.cycle }}/${{ 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"]}' # Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит, # решает этот шаг, а не модель: всё остальное откатывается. - name: Опубликовать документ ревью env: TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} BRANCH: ${{ steps.branch.outputs.name }} NUM: ${{ github.event.issue.number }} STAGE: ${{ needs.guard.outputs.stage }} CYCLE: ${{ needs.guard.outputs.cycle }} run: | # Ветки задачи может не быть: у задач, размеченных до появления # конвейера, ТЗ лежит прямо в 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" git checkout -- . 2>/dev/null || true git clean -fd -e docs/reviews -e node_modules >/dev/null 2>&1 || true git add docs/reviews 2>/dev/null || true 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::вердикт есть, а документа $doc нет ни в рабочей копии, ни в $target — ревью без артефакта (#171)" exit 1 fi 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" - name: Решение по вердикту id: decide env: OUT: ${{ steps.review.outputs.structured_output }} STAGE: ${{ needs.guard.outputs.stage }} run: | verdict=$(echo "$OUT" | jq -r '.verdict') high=$(echo "$OUT" | jq -r '.high') 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" # S8-merged утверждает, что код в dev. Значит слияние обязано произойти # ДО метки, иначе она врёт в промежутке. # # При конфликте шаг НЕ падает и метку не оставляет на месте. Первая # редакция делала именно так, и это оказалось тупиком: автор ждёт смену # метки, метка не менялась, и он тридцать раз опрашивал впустую, чтобы # затем отчитаться «лимит исчерпан» — при зелёном вердикте. Инвариант # теперь жёстче: ПОСЛЕ ПРОГОНА РЕВЬЮ МЕТКА МЕНЯЕТСЯ ВСЕГДА. - name: Слить ветку в dev id: merge if: needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' env: TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} BRANCH: ${{ steps.branch.outputs.name }} NUM: ${{ github.event.issue.number }} run: | if [ -z "$BRANCH" ]; then echo "::error::ветки задачи нет — сливать нечего" echo "merged=false" >> "$GITHUB_OUTPUT" exit 0 fi git fetch -q origin dev git checkout -q -B merge-into-dev "origin/$BRANCH" if ! git -c user.name="claude[bot]" \ -c user.email="209825114+claude[bot]@users.noreply.github.com" \ rebase origin/dev; then git rebase --abort || true echo "merged=false" >> "$GITHUB_OUTPUT" echo "::warning::ветка $BRANCH не сливается в dev без конфликта" cat > /tmp/conflict.md <> "$GITHUB_OUTPUT" echo "слито в dev: $(git rev-parse --short HEAD)" - name: Переставить метку env: # Именно PAT: с GITHUB_TOKEN следующий шаг конвейера не запустится. GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} NUM: ${{ github.event.issue.number }} FROM: ${{ steps.decide.outputs.from }} # Зелёное код-ревью без слияния ведёт не в S8-merged, а обратно к # автору: метка утверждала бы, что код в dev, а его там нет. TO: ${{ (needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.merged != 'true') && 'S6-in-progress' || steps.decide.outputs.to }} run: | gh issue edit "$NUM" --repo "${{ github.repository }}" \ --add-label "$TO" --remove-label "$FROM" echo "$FROM -> $TO" - name: Позвать владельца, если ревью упало if: failure() env: GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | # Тело через heredoc, а не многострочный --body: строка с нулевым # отступом обрывает блок YAML и оставляет незакрытую кавычку. cat > /tmp/failure.md <