name: Ревью-конвейер # Событийный конвейер процесса (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 стоит на job, а не на workflow (#499). На уровне workflow в # группу issue попадал КАЖДЫЙ прогон — и от `polish`, и от `P2`, и от метки, # которую переставил сам конвейер. GitHub держит в группе один идущий и один # ожидающий прогон, и новый ожидающий вытесняет старого: ожидавший S7-code-review # отменялся первой же посторонней меткой. Теперь посторонняя метка не запускает # ни одной job (`if` на guard) и в группу не входит. permissions: contents: read issues: write # Обязательно: claude-code-action получает OIDC-токен для авторизации # GitHub App. Без этого прогон падает с «Could not fetch an OIDC token». id-token: write jobs: guard: name: "Страж: ребейз на dev и предпосылки ревью" # Только статусные метки этапов ревью запускают конвейер (#499). Остальные # события помечаются skipped и не занимают место в группе concurrency. if: github.event.label.name == 'S4-spec-review' || github.event.label.name == 'S7-code-review' runs-on: ubuntu-latest concurrency: group: process-issue-${{ github.event.issue.number }} cancel-in-progress: false outputs: stage: ${{ steps.decide.outputs.stage }} cycle: ${{ steps.decide.outputs.cycle }} 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@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) if ! gh api "repos/$REPO/contents/docs/reviews?ref=$target" --jq '.[].name' \ > "$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 обрезается, счёт по файлам ненадёжен" 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" review: name: "Ревью (Claude): вердикт в issue" needs: guard if: needs.guard.outputs.stage != '' runs-on: ubuntu-latest concurrency: group: process-issue-${{ github.event.issue.number }} cancel-in-progress: false # Время — единственный настоящий ограничитель зациклившегося прогона. timeout-minutes: 45 steps: - 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" # Якоря материала, устойчивые к ребейзу (#413, #414). SHA коммита # ребейз меняет — содержимое нет: git адресует деревья и блобы их # хешем. Снимаются здесь, где рабочая копия ЕЩЁ равна тому, что # ревьюер прочтёт; в шаге публикации дерево уже сброшено на целевую # ветку, и спрашивать его поздно. echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" 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" 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 }} BRANCH: ${{ steps.branch.outputs.name }} # 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 коммит(ов) — привожу ветку" 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 { echo 'conflict=true' echo 'conflicts<> "$GITHUB_OUTPUT" echo "::warning::ветка $BRANCH не ребейзится на dev без конфликта — ревью не запускается" printf 'конфликтуют:\n%s\n' "${files:-(git не назвал файлы)}" exit 0 fi # --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 # Локальная ссылка обновляется тоже: шаг слияния берёт 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' run: | echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" echo "материал ревью: $(git rev-parse --short HEAD)" # Повторное применение зелёного вердикта без вызова модели (#499). Сценарий # #437 r4: зелёный r3 не слился (страж #312), задача вернулась в S6 и тут же # в S7, и ревьюер двенадцать минут заново разбирал дерево, в котором с r3 # изменился ровно один файл — его собственный документ r3. Правило узкое: # последний документ этапа несёт записанный конвейером вердикт `green` # с High 0, и `git diff` между его якорем-деревом и HEAD пуст вне # docs/reviews/**. Любое иное отличие — ребейз, тест, фикстура, скрипт, # ТЗ — даёт полный разбор. Только этап code: материал spec может жить в # теле issue, которого в дереве нет. - name: "Зелёный вердикт прошлого захода применим без ревью (#499)" id: reuse if: steps.rebase.outputs.conflict != 'true' && needs.guard.outputs.stage == 'code' env: NUM: ${{ github.event.issue.number }} run: | out=$(node scripts/review-doc-guard.mjs --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD) printf '%s\n' "$out" printf '%s\n' "$out" >> "$GITHUB_OUTPUT" if printf '%s\n' "$out" | grep -qx 'reuse=true'; then echo "вердикт прошлого захода применяется повторно: модель не вызывается" >> "$GITHUB_STEP_SUMMARY" fi # Конфликт возвращает задачу автору ДО ревью. Инвариант «после прогона # метка меняется всегда» при этом держится: возврат в S6-in-progress — # тоже смена метки, и автор не ждёт впустую. - 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 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 - name: Validate красный — вернуть автору без ревью if: steps.rebase.outputs.conflict != 'true' && steps.gate.outputs.proceed != 'true' 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 # Зависимости ставятся ПОСЛЕ переключения на ветку задачи: 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" - name: Review id: review if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' uses: anthropics/claude-code-action@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 }} 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). Номер захода нужен для имени документа — два документа с одинаковым номером затёрли бы друг друга. ${{ steps.rebase.outputs.note }} **Если цикл не первый — объём разбора по дельте, а не заново** (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. Поэтому граница не «только находки», а «находки плюс всё, до чего дотягивается дельта». Прочитай в этом порядке, прежде чем судить: 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`. **Материал ревью — ровно `${{ steps.material.outputs.sha }}`, рабочая копия уже на нём.** Не делай `git fetch`, `git pull` и `git checkout` на другой коммит: вердикт привязан к этому SHA (#312), и страж слияния сверяет вершину ветки с ним. Если автор в issue называет более новый коммит, которого в материале нет, — это находка «материал не был запушен до метки», а не повод подтянуть его самому (#437 r3→r4 стоил лишнего раунда именно так, #499). Ручного тестирования в цикле нет, поэтому именно ты отвечаешь на вопрос «оно вообще работает». По каждому AC: либо он доказан автотестом и ты убедился, что тест умеет падать, либо разобран по коду с явной записью «проверено чтением, не исполнением». «Verified» без названной команды и её результата доказательством не является. Зависимости уже установлены workflow, Chromium тоже — `npm ci` выполнять не нужно. Проверь трейлеры Issue и User-Visible, при User-Visible: yes — правки в оба changelog в том же коммите. **Объём гейтов соразмерен задаче.** Прогонять весь набор на каждой правке — не тщательность, а потеря времени: полные наборы это предрелизный гейт (PROCESS.md §8), а не гейт ревью. ${{ steps.validated.outputs.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`, смысловая — твоя. Дисциплина «тест должен уметь падать» не отменяется, но применяется к тем тестам, которые ты прогонял. **В комментарии обязателен перечень: какие гейты прогнал, какие нет и почему.** Это условие честности такого сужения: непрогнанный гейт становится видимым решением, а не молчаливым пропуском. Раздел «чего не проверял» в документе ревью — не формальность, а главный его раздел на коротких задачах. Ты НЕ правишь ни ТЗ, ни продуктовый код. Только оцениваешь. Серьёзность: High блокирует; Medium В СКОУПЕ задачи чинится в ней же — без High это жёлтый вердикт и возврат автору, отдельный issue НЕ заводится (решение владельца 2026-08-19, #202: заведение и обслуживание issue дороже правки на месте); Low либо правится, либо снимается с записью. Жёлтый вердикт допустим и при полностью выполненных AC, если изменение не решает заявленный сценарий или ухудшает смежный. Продуктовое рассуждение расширяет вопросы, но не отменяет AC и не даёт права менять скоуп. Только Medium-находку ВНЕ скоупа задачи (попутный дефект соседнего поведения, который в этой ветке чинить нельзя) заведи отдельным issue со ссылкой на #${{ github.event.issue.number }} и метками: тип, приоритет, S1-new. «Оставили в тексте ревью» закрытием не считается и прямо запрещено §12. Напиши полный документ ревью в файл, путь которого лежит в переменной окружения REVIEW_DOC (абсолютный, ВНЕ репозитория). Почему не в docs/reviews: документ там был некоммитнутым файлом того же дерева, которое ты мутируешь, проверяя «умеет ли тест падать». На #220 три раунда подряд документ исчезал — восстановление дерева (`git checkout -- .`, `git clean -fd`) сносит собственный артефакт ревью, потому что он untracked. В репозиторий его положит шаг публикации, взяв из 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"]}' # Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит, # решает этот шаг, а не модель: всё остальное откатывается. - name: Опубликовать документ ревью if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' 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 }} SOURCE: ${{ runner.temp }}/review-document.md MATERIAL_SHA: ${{ steps.branch.outputs.sha }} MATERIAL_TREE: ${{ steps.branch.outputs.tree }} MATERIAL_SPECS: ${{ steps.branch.outputs.specs }} # Вердикт из structured_output попадает в блок якорей (#499): по нему # следующий заход решает, можно ли применить зелёный вердикт повторно. OUT: ${{ steps.review.outputs.structured_output }} run: | verdict=$(printf '%s' "$OUT" | jq -r '.verdict // empty' 2>/dev/null || true) high=$(printf '%s' "$OUT" | jq -r '.high // empty' 2>/dev/null || true) # Ветки задачи может не быть: у задач, размеченных до появления # конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча # выходил с нулём, и разбор ревью терялся — оставался только вердикт # комментарием. Это тот же тихий отказ: шаг сообщал об успехе тем, что # ничего не сделал. Документ ложится туда же, где лежит само ТЗ. 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" \ --verdict="$verdict" --high="$high" else echo "::warning::$SOURCE не найден — документа для публикации нет" fi # Индексируется ровно один путь, а не каталог: `git add docs/reviews` # забрал бы всё, что там окажется, а после reset там не должно быть # ничего постороннего — но полагаться на «не должно» здесь нельзя. git add -- "$doc" 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::вердикт есть, а документа нет: ни $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.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 }} 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.gate.outputs.proceed == 'true' env: OUT: ${{ steps.review.outputs.structured_output }} STAGE: ${{ needs.guard.outputs.stage }} REUSE: ${{ steps.reuse.outputs.reuse }} REUSE_DOC: ${{ steps.reuse.outputs.doc }} REUSE_ROUND: ${{ steps.reuse.outputs.round }} REUSE_TREE: ${{ steps.reuse.outputs.tree }} GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} NUM: ${{ github.event.issue.number }} run: | 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.gate.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 }} 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: ${{ steps.branch.outputs.name }} NUM: ${{ github.event.issue.number }} MATERIAL_SHA: ${{ steps.material.outputs.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.gate.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: 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 <