From 41325a852f378e5cb9cb7daa245b4b04afddddec Mon Sep 17 00:00:00 2001 From: Matysh Date: Thu, 20 Aug 2026 23:17:16 +0300 Subject: [PATCH 01/13] fix: spend the review budget on blocking verdicts only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pipeline punished what it prescribed: after a failed merge it tells the author to rebase and restore S7-code-review, and that attempt finished the budget. On #225 (light track, limit 2) the sequence yellow, green, rebase produced review-4 on a task whose code review was green and whose CI was green, with no product change after the verdict — the owner had to arbitrate work that was already accepted. A cycle under section 4 is a verdict with blocking findings followed by a return to the author, so only yellow and red verdicts spend the budget now. A green verdict returned nothing and consumes nothing, which also removes any need to mark rebase re-runs specially. Attempts and cycles are now separate quantities. The attempt number keeps naming the document, because two runs sharing a number would overwrite each other's review artefact, while the limit compares blocking cycles only. The exhaustion comment lists the verdicts it counted, and the guard no longer strips review-4 — it reports the recount and leaves the decision with the owner. Rule 7 of the process gate follows: its document threshold rises above the cycle limit, because legitimate attempts can exceed cycles and a threshold equal to the limit would refuse the very rebase the pipeline demands. Issue: #227 User-Visible: no --- .github/workflows/process.yml | 79 ++++++++++++++++++++++++++--------- 1 file changed, 60 insertions(+), 19 deletions(-) diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index a080a149..6a615679 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -39,6 +39,7 @@ jobs: outputs: stage: ${{ steps.decide.outputs.stage }} cycle: ${{ steps.decide.outputs.cycle }} + spent: ${{ steps.decide.outputs.spent }} limit: ${{ steps.decide.outputs.limit }} steps: - id: decide @@ -63,19 +64,41 @@ jobs: limit=4 if [ "$SMALL" = "true" ] || [ "$TRIVIAL" = "true" ]; then limit=2; fi - # Счётчик считает вердикты ТОЛЬКО своего этапа. Раньше он брал все - # подряд, и вердикт по ТЗ съедал цикл из бюджета код-ревью: на #89 - # первое код-ревью получило r2/4. На задаче с двумя циклами ТЗ второе - # код-ревью упиралось бы в review-4 после одной правки. + # Считаются ДВЕ РАЗНЫЕ величины, и это не педантизм (#227). # - # Этап опознаётся по имени документа в теле комментария. Если документа - # нет, вердикт не посчитается — недосчёт даёт лишний цикл, а перерасчёт - # остановил бы работу досрочно; из двух ошибок выбрана обратимая. - done_cycles=0 + # `attempt` — сколько раз ревью уже отработало на этом этапе. Он нужен + # только для имени документа и метки: два захода с одинаковым номером + # означают, что второй документ перезапишет первый и артефакт ревью + # исчезнет. + # + # `spent` — сколько циклов израсходовано из бюджета §4. Цикл — это + # «отправка на ревью → вердикт с блокирующими находками → возврат + # автору», поэтому бюджет тратят ТОЛЬКО жёлтые и красные вердикты. + # Зелёный ничего на правки не вернул и цикла не образует. + # + # Раньше обе роли исполнял один счётчик всех вердиктов, и конвейер + # наказывал за то, что предписывал сам: при неудавшемся слиянии он + # велит вернуть S7-code-review после ребейза, и этот заход добивал + # бюджет. На #225 (лёгкий трек, лимит 2) последовательность + # жёлтый → зелёный → ребейз дала review-4 на задаче с зелёным ревью и + # зелёным CI: работа встала, хотя после вердикта не было ни одной + # правки продуктового кода. + # + # Вердикты считаются ТОЛЬКО своего этапа: иначе вердикт по ТЗ съедал + # цикл из бюджета код-ревью (#89 получило r2/4). Этап опознаётся по + # имени документа в теле комментария; документа нет — вердикт не + # посчитается. Недосчёт даёт лишний заход, перерасчёт остановил бы + # работу досрочно: из двух ошибок выбрана обратимая. + attempt=1; spent=0; spent_list="" 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") + comments=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json comments) + of_stage="[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"$marker\"))]" + # Блокирующим считается вердикт, у которого в строке вердикта стоит + # «жёлтый» или «красный». Регистр и окружение слова не важны. + blocking="$of_stage | map(select(.body | test(\"Вердикт:[^\\n]*(жёлт|красн)\"; \"i\")))" + attempt=$(( $(printf '%s' "$comments" | jq -r "$of_stage | length") + 1 )) + spent=$(printf '%s' "$comments" | jq -r "$blocking | length") + spent_list=$(printf '%s' "$comments" | jq -r "$blocking | map(\"- \" + .url) | join(\"\\n\")") fi # Отказ обязан быть виден в issue, а не только в логе прогона. @@ -109,19 +132,33 @@ jobs: 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)." - elif [ "$done_cycles" -ge "$limit" ]; then - echo "циклов этапа $stage пройдено $done_cycles из $limit — лимит исчерпан" + "на 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 \ - "Лимит циклов ревью исчерпан ($done_cycles из $limit на этапе \`$stage\`). Пятого захода нет: решение владельца — разделить задачу, отклонить или арбитраж (PROCESS.md §4)." + "Лимит циклов ревью исчерпан: блокирующих циклов $spent из $limit на этапе \`$stage\` (заход $attempt). Следующего захода нет: решение владельца — разделить задачу, отклонить или арбитраж (PROCESS.md §4). + + Учтены вердикты с блокирующими находками — зелёные бюджет не тратят: + $spent_list" stage="" else - echo "этап $stage, цикл $((done_cycles + 1)) из $limit" + echo "этап $stage, заход $attempt, блокирующих циклов $spent из $limit" fi echo "stage=$stage" >> "$GITHUB_OUTPUT" - echo "cycle=$((done_cycles + 1))" >> "$GITHUB_OUTPUT" + echo "cycle=$attempt" >> "$GITHUB_OUTPUT" + echo "spent=$spent" >> "$GITHUB_OUTPUT" echo "limit=$limit" >> "$GITHUB_OUTPUT" review: @@ -230,7 +267,11 @@ jobs: Этап: ${{ needs.guard.outputs.stage }} spec — ревью ТЗ (PROCESS.md §2.4) code — код-ревью (PROCESS.md §2.7) - Цикл: r${{ needs.guard.outputs.cycle }} + Заход: r${{ needs.guard.outputs.cycle }} · блокирующих циклов израсходовано ${{ needs.guard.outputs.spent }} из ${{ needs.guard.outputs.limit }} + Бюджет §4 тратят только жёлтые и красные вердикты: зелёный + ничего не вернул на правки и цикла не образует (#227). + Номер захода нужен для имени документа — два документа с + одинаковым номером затёрли бы друг друга. **Если цикл не первый — объём разбора по дельте, а не заново** (PROCESS.md §2.9, issue #214). Раньше промпт был одинаковым для @@ -359,7 +400,7 @@ jobs: Затем оставь в issue краткий комментарий: вердикт, ключевые находки и ссылка на документ. Первой строкой — вердикт в формате §7.2: - `Вердикт: зелёный/жёлтый/красный · цикл r${{ needs.guard.outputs.cycle }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → в задаче | #…` + `Вердикт: зелёный/жёлтый/красный · заход r${{ needs.guard.outputs.cycle }} · блокирующих циклов ${{ needs.guard.outputs.spent }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → в задаче | #…` («→ #…» — только у Medium вне скоупа; находки в скоупе возвращаются автору жёлтым) Затем верни JSON по схеме. Это последнее действие и оно обязательно: From 5fdedf39d6d901410de4e83fb6aa56b9b35c6b46 Mon Sep 17 00:00:00 2001 From: Matysh Date: Fri, 21 Aug 2026 10:32:21 +0300 Subject: [PATCH 02/13] fix: keep the review document outside the tree the reviewer mutates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three code-review rounds on #220 published a verdict and then failed the run: the document never reached the branch, so the #171 guard refused before the label step and neither the merge nor S8-merged happened. The cause was structural. The document lived as an untracked file inside the very checkout the reviewer edits while proving that a test can fail, and restoring that tree — git checkout, git clean — deletes an untracked file. Spec rounds survived only because they never mutate anything. The reviewer now writes to REVIEW_DOC under RUNNER_TEMP, outside the repository, and the publish step copies it into docs/reviews before committing. Tree cleanup can no longer destroy the artefact, and the reviewer no longer needs to touch docs/reviews at all. Verified against a local git fixture on five paths: document outside the repo with a mutated tree (published, and the code mutation does not leak), nothing anywhere (loud failure, exit 1), document only in the working copy (still published — the clean exclusion stays for exactly this), document already committed by the reviewer (recognised, no duplicate), and a branch that moved during the review (rebased, both commits kept). Issue: #220 User-Visible: no --- .github/workflows/process.yml | 46 +++++++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index 6a615679..e6818e93 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -255,6 +255,10 @@ jobs: - name: Review id: review uses: anthropics/claude-code-action@v1 + env: + # Вне рабочей копии: восстановление дерева ревьюером не должно + # уничтожать его собственный артефакт (#220). + REVIEW_DOC: ${{ runner.temp }}/review-document.md with: # Подписка, а не отдельный счёт API: токен выпускается через # `claude setup-token` (Pro/Max). Действуют лимиты подписки. @@ -388,15 +392,25 @@ jobs: тип, приоритет, S1-new. «Оставили в тексте ревью» закрытием не считается и прямо запрещено §12. - Напиши полный документ ревью в файл - docs/reviews/-REVIEW-${{ github.event.issue.number }}-r${{ needs.guard.outputs.cycle }}.md + Напиши полный документ ревью в файл, путь которого лежит в + переменной окружения REVIEW_DOC (абсолютный, ВНЕ репозитория). + + Почему не в docs/reviews: документ там был некоммитнутым файлом того + же дерева, которое ты мутируешь, проверяя «умеет ли тест падать». На + #220 три раунда подряд документ исчезал — восстановление дерева + (`git checkout -- .`, `git clean -fd`) сносит собственный артефакт + ревью, потому что он untracked. В репозиторий его положит шаг + публикации, взяв из REVIEW_DOC; тебе трогать docs/reviews не нужно. + + В самом репозитории не создавай файлов вообще: любые изменения в + рабочей копии будут отброшены. (SPEC для этапа spec, CODE для code): скоуп, как проверялось, находки с воспроизведением, что проверено и корректно, чего не проверял. Для r2 и дальше добавь два раздела: «Закрытие раунда r» — таблица «находка | чем закрыта | где это видно», и «Унаследовано из r» — что принято без повторной проверки, с - документом и SHA. Каталог docs/reviews/ создай, если его нет. Больше не - пиши ничего: любой файл вне docs/reviews/ опубликован не будет. + документом и SHA. Файл пиши по пути из REVIEW_DOC — ничего другого + публиковать не нужно и не будет. Затем оставь в issue краткий комментарий: вердикт, ключевые находки и ссылка на документ. Первой строкой — вердикт в формате §7.2: @@ -419,6 +433,7 @@ jobs: NUM: ${{ github.event.issue.number }} STAGE: ${{ needs.guard.outputs.stage }} CYCLE: ${{ needs.guard.outputs.cycle }} + SOURCE: ${{ runner.temp }}/review-document.md run: | # Ветки задачи может не быть: у задач, размеченных до появления # конвейера, ТЗ лежит прямо в dev. Раньше шаг в этом случае молча @@ -432,8 +447,29 @@ jobs: 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 + # docs/reviews исключён из уборки: ревьюер мог написать документ по + # старому пути, и клин не должен его съесть до `git add` — ровно так + # оба пути остаются работоспособными. git clean -fd -e docs/reviews -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") байт)" + else + # Совместимость: ревьюер мог написать по старому пути, если промпт + # ещё не обновился в этой ветке. + echo "::warning::$SOURCE не найден — ищу документ в рабочей копии" + fi git add docs/reviews 2>/dev/null || true if git diff --cached --quiet; then # Пустая рабочая копия — ещё не провал: ревьюер иногда коммитит @@ -449,7 +485,7 @@ jobs: # остались только комментариями, метки переставились, и пропажу # заметило лишь следующее ревью — issue #171. Падение ДО шага с # меткой сохраняет инвариант «метка не сменилась = прогон упал». - echo "::error::вердикт есть, а документа $doc нет ни в рабочей копии, ни в $target — ревью без артефакта (#171)" + echo "::error::вердикт есть, а документа нет: ни $SOURCE, ни $doc в рабочей копии, ни $doc в $target — ревью без артефакта (#171, #220)" exit 1 fi git -c user.name="claude[bot]" \ From 35bb2b58b72e1fcb9cb7f7b7b21b7113b9690486 Mon Sep 17 00:00:00 2001 From: Matysh Date: Fri, 21 Aug 2026 10:37:54 +0300 Subject: [PATCH 03/13] docs: finish the sentence the reviewer prompt lost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous change replaced the document path with REVIEW_DOC and left a parenthetical hanging: the prompt jumped from "create no files in the repository" straight into "(SPEC for the spec stage, CODE for code): scope, how it was checked…", with the sentence that introduced the document structure gone. The prompt now says plainly that the publish step derives the name in docs/reviews, and the content requirements start a paragraph of their own. Issue: #220 User-Visible: no --- .github/workflows/process.yml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index e6818e93..eda12492 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -403,14 +403,15 @@ jobs: публикации, взяв из REVIEW_DOC; тебе трогать docs/reviews не нужно. В самом репозитории не создавай файлов вообще: любые изменения в - рабочей копии будут отброшены. - (SPEC для этапа spec, CODE для code): скоуп, как проверялось, - находки с воспроизведением, что проверено и корректно, чего не - проверял. Для r2 и дальше добавь два раздела: «Закрытие раунда - r» — таблица «находка | чем закрыта | где это видно», и - «Унаследовано из r» — что принято без повторной проверки, с - документом и SHA. Файл пиши по пути из REVIEW_DOC — ничего другого - публиковать не нужно и не будет. + рабочей копии будут отброшены. Имя документа в docs/reviews шаг + публикации соберёт сам — SPEC-REVIEW для этапа spec, CODE-REVIEW для + code, с номером issue и заходом. + + Содержание документа: скоуп, как проверялось, находки с + воспроизведением, что проверено и корректно, чего не проверял. Для + r2 и дальше добавь два раздела: «Закрытие раунда r» — таблица + «находка | чем закрыта | где это видно», и «Унаследовано из r» — + что принято без повторной проверки, с документом и SHA. Затем оставь в issue краткий комментарий: вердикт, ключевые находки и ссылка на документ. Первой строкой — вердикт в формате §7.2: From e246d325801a7ecbea387077159bd773b925cde9 Mon Sep 17 00:00:00 2001 From: Matysh Date: Sat, 22 Aug 2026 11:44:31 +0300 Subject: [PATCH 04/13] ci: name check-docs in the review gate set Issue: #237 User-Visible: no --- .github/workflows/process.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index eda12492..5469af2a 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -353,12 +353,20 @@ jobs: Всегда, они дешёвые, и в повторном раунде тоже: код изменился, а стоят они минуты: `npx tsc --noEmit`, `npm test`, `npm run build` со сверкой трёх - копий бандла. + копий бандла. Плюс `node scripts/check-docs.mjs`, если diff трогает + `src/**`: отпечаток скриншотов документации считается по всему + `src/**`, поэтому любая правка фронтенда делает его устаревшим — + выбирать тут нечего. Пропуск этого шага в #230 и #234 оставил `dev` + с красным job `docs` до следующей задачи (#237). По необходимости, и «необходимость» определяется diff'ом и AC: - браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс - относящиеся к тронутым поверхностям. Их 127; прогон всех уместен - только когда задача действительно задевает всё; + относящиеся к тронутым поверхностям. Их 163; прогон всех уместен + только когда задача действительно задевает всё. Выбирать по теме + недостаточно: регресс #234 поймал `smoke_wall_junctions`, который + по названию про стыки стен, а не про толщину отрезка. Надёжнее + grep — искать в `demo/smoke_*.mjs` имена изменённых функций и + полей, и прогонять найденное; - `npm run golden:verify` — если diff может изменить видимый результат: рендер, геометрия, стили, слои; - `python -m pytest tests_backend -q` — если тронут From 61c401b810cf7e5294c504cb591abff09c17b33e Mon Sep 17 00:00:00 2001 From: Matysh Date: Sat, 22 Aug 2026 18:33:45 +0300 Subject: [PATCH 05/13] ci: derive the browser-smoke shortlist from the diff Issue: #241 User-Visible: no --- .github/workflows/process.yml | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index 5469af2a..93bfc1d4 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -360,13 +360,19 @@ jobs: с красным job `docs` до следующей задачи (#237). По необходимости, и «необходимость» определяется diff'ом и AC: - - браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс - относящиеся к тронутым поверхностям. Их 163; прогон всех уместен - только когда задача действительно задевает всё. Выбирать по теме - недостаточно: регресс #234 поймал `smoke_wall_junctions`, который - по названию про стыки стен, а не про толщину отрезка. Надёжнее - grep — искать в `demo/smoke_*.mjs` имена изменённых функций и - полей, и прогонять найденное; + - браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс те, + что печатает `node scripts/smoke-select.mjs --base --head `. + Их 167; прогон всех уместен только когда задача действительно + задевает всё. Выбирать по теме недостаточно: регресс #234 поймал + `smoke_wall_junctions`, который по названию про стыки стен, а не + про толщину отрезка. Инструмент печатает три вида ответа, и они + разные: «прямое совпадение» — смок называет изменённый символ, + «зарегистрированная связь» — смок проверяет следствие контракта, + не называя его, «НЕОПРЕДЕЛЁННОСТЬ» — связь не доказана, и это не + разрешение ничего не прогонять. Вывод инструмента прикладывается + к комментарию ревью вместе с решением по каждой строке: прогнал + либо не прогнал и почему. Слабые связи (одно распространённое + имя) — повод посмотреть, а не обязанность прогонять; - `npm run golden:verify` — если diff может изменить видимый результат: рендер, геометрия, стили, слои; - `python -m pytest tests_backend -q` — если тронут From 839e3316c3def5cf29db76a0e421c83bf65463a3 Mon Sep 17 00:00:00 2001 From: Matysh Date: Sat, 22 Aug 2026 19:18:39 +0300 Subject: [PATCH 06/13] ci: capture documentation screenshots in one place Issue: #246 User-Visible: no --- .github/workflows/docs-screenshots.yml | 67 ++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 .github/workflows/docs-screenshots.yml diff --git a/.github/workflows/docs-screenshots.yml b/.github/workflows/docs-screenshots.yml new file mode 100644 index 00000000..7e1be872 --- /dev/null +++ b/.github/workflows/docs-screenshots.yml @@ -0,0 +1,67 @@ +# Скриншоты документации снимаются здесь и только здесь (#246). +# +# Съёмка на машине исполнителя даёт байтово разный PNG при одинаковом кадре: +# сглаживание и хинтинг зависят от окружения. Измерено на истории — пересъёмка +# в #231 изменила два файла из девяти на 7–8 байт, набор с беты все девять +# целиком. Одно окружение убирает этот шум насовсем. +# +# Джоба ничего не коммитит: она публикует артефакт, который человек принимает +# локально через `npm run docs:accept -- --reviewed --from=<распакованный>`. +# Та же конструкция, что у golden-эталонов, и по той же причине: картинки +# попадают в репозиторий через явное решение, а не через бота. +name: Docs screenshots + +on: + workflow_dispatch: + inputs: + ref: + description: Ветка или SHA, с которого снимать + required: false + default: dev + +permissions: + contents: read + +jobs: + capture: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref }} + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: npm + - run: npm ci + # Тот же кэш и тот же отказ от --with-deps, что в smoke/golden (#175, #206): + # системные библиотеки Chromium уже в образе раннера. + - name: Кэш браузеров Playwright + id: pw + uses: actions/cache@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + - name: Install pinned Chromium + if: steps.pw.outputs.cache-hit != 'true' + run: npx playwright install chromium + - name: Build the bundle the screenshots must come from + run: npm run build + - name: Capture + run: node demo/docs/capture.mjs + # Что именно изменилось — видно в логе прогона, до всякой приёмки: если + # изменились все девять файлов, значит съёмка велась не тем браузером, и + # принимать такой набор не надо. + - name: Что изменилось + run: | + git status --porcelain docs/images + echo "--- изменившихся PNG: $(git diff --name-only docs/images | grep -c png || true)" + node -e "const m=require('./docs/images/screenshots.json');console.log('Chromium:',m.chromium)" + - name: Upload candidate + uses: actions/upload-artifact@v7 + with: + name: docs-screenshots + path: | + docs/images/*.png + docs/images/screenshots.json + if-no-files-found: error From 944a36ee675d788de276942d15bcf5a5f467562f Mon Sep 17 00:00:00 2001 From: Matysh Date: Sat, 22 Aug 2026 19:31:01 +0300 Subject: [PATCH 07/13] ci: make the screenshot verdict compare browsers, not counts Issue: #246 User-Visible: no --- .github/workflows/docs-screenshots.yml | 29 ++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/.github/workflows/docs-screenshots.yml b/.github/workflows/docs-screenshots.yml index 7e1be872..92de4ad8 100644 --- a/.github/workflows/docs-screenshots.yml +++ b/.github/workflows/docs-screenshots.yml @@ -49,14 +49,31 @@ jobs: run: npm run build - name: Capture run: node demo/docs/capture.mjs - # Что именно изменилось — видно в логе прогона, до всякой приёмки: если - # изменились все девять файлов, значит съёмка велась не тем браузером, и - # принимать такой набор не надо. - - name: Что изменилось + # Вердикт до всякой приёмки. Само число изменившихся файлов ничего не + # говорит: набор, снятый другим браузером, меняет их все, и это нормально + # ровно один раз — при переходе на канонический прогон. Сравнивать надо + # браузер: тот же Chromium и десять изменившихся картинок означают, что + # изменился продукт (или что-то не так), другой Chromium — ожидаемую + # разницу рендеринга. + - name: Вердикт run: | git status --porcelain docs/images - echo "--- изменившихся PNG: $(git diff --name-only docs/images | grep -c png || true)" - node -e "const m=require('./docs/images/screenshots.json');console.log('Chromium:',m.chromium)" + changed=$(git diff --name-only docs/images | grep -c png || true) + before=$(git show HEAD:docs/images/screenshots.json | node -e \ + "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{console.log(JSON.parse(s).chromium||'')}catch{console.log('')}})") + after=$(node -e "console.log(require('./docs/images/screenshots.json').chromium)") + echo "--- изменившихся PNG: $changed" + echo "--- Chromium: было «${before:-не записан}», стало «$after»" + if [ "$before" = "$after" ] && [ "$changed" -gt 0 ]; then + echo "ВЕРДИКТ: тот же браузер, а картинки изменились — изменился продукт." + echo "Смотрите на кадры: если изменение ожидаемое, принимайте." + elif [ "$before" != "$after" ]; then + echo "ВЕРДИКТ: браузер другой, поэтому переписаны все кадры сразу." + echo "Это ожидаемо один раз — при переходе на канонический прогон." + echo "Если Chromium сменился неожиданно, сверьте закреплённую версию в package-lock." + else + echo "ВЕРДИКТ: ничего не изменилось, принимать нечего." + fi - name: Upload candidate uses: actions/upload-artifact@v7 with: From 735b98c8ca7c27a870252309fad5bffdd3b4f38c Mon Sep 17 00:00:00 2001 From: Matysh Date: Sun, 23 Aug 2026 09:41:12 +0300 Subject: [PATCH 08/13] ci: name the model invariants in the review gate set Issue: #254 User-Visible: no --- .github/workflows/process.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index 93bfc1d4..5740f77a 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -359,6 +359,16 @@ jobs: выбирать тут нечего. Пропуск этого шага в #230 и #234 оставил `dev` с красным job `docs` до следующей задачи (#237). + Если diff трогает геометрию или ссылки на неё — рёбра комнат, + записи толщины, `layout`, `marker.space`, `open_spans` — обязательны + инварианты модели (#254): `npm test` уже гоняет их на всех моделях + проекта, а на конкретной конфигурации они проверяются командой + `npm run invariants -- --config <экспорт или ответ config/get>`. + Два вопроса, на которые они отвечают, и оба уже стоили продукту + дефектов: не исчезла ли запись толщины (#253) и разрешима ли каждая + ссылка (#244, #252). Если задача меняет геометрию, а инварианты в + отчёте не названы — это непрогнанный гейт, а не мелочь. + По необходимости, и «необходимость» определяется diff'ом и AC: - браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс те, что печатает `node scripts/smoke-select.mjs --base --head `. From d27c3f023e7093e1d6c68af5472bcf312b8f708f Mon Sep 17 00:00:00 2001 From: Matysh Date: Sun, 23 Aug 2026 09:50:35 +0300 Subject: [PATCH 09/13] ci: ask the reviewer about numbers seen twice Issue: #254 User-Visible: no --- .github/workflows/process.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index 5740f77a..6db37b79 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -390,6 +390,14 @@ jobs: - performance-профили — если названы в AC либо тронуты чувствительные к перфу пути. + **Одно число — один источник.** Если дифф добавляет или меняет + величину, видимую пользователю, назови в отчёте прямо: какое число + видно дважды (превью против записи, подпись против площади, + подсветка инструмента против сохранённого значения) и один ли у него + источник. Три дефекта подряд имели именно эту причину — #234, #233 и + способ, которым #234 обнаружили. Механическая часть закреплена + тестом `test/single-source-numbers.test.mjs`, смысловая — твоя. + Дисциплина «тест должен уметь падать» не отменяется, но применяется к тем тестам, которые ты прогонял. From b6c3c5ced3400d4308b60c6f1bdf3004c98a77a6 Mon Sep 17 00:00:00 2001 From: Matysh Date: Sun, 23 Aug 2026 09:55:28 +0300 Subject: [PATCH 10/13] ci: drop the hardcoded smoke count from the prompt Issue: #254 User-Visible: no --- .github/workflows/process.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index 6db37b79..f78c6f60 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -372,8 +372,10 @@ jobs: По необходимости, и «необходимость» определяется diff'ом и AC: - браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс те, что печатает `node scripts/smoke-select.mjs --base --head `. - Их 167; прогон всех уместен только когда задача действительно - задевает всё. Выбирать по теме недостаточно: регресс #234 поймал + Сколько их всего — считает `ls demo/smoke_*.mjs | wc -l`; вшитое + в этот текст число трижды расходилось с деревом, поэтому его + здесь больше нет. Прогон всех уместен только когда задача + действительно задевает всё. Выбирать по теме недостаточно: регресс #234 поймал `smoke_wall_junctions`, который по названию про стыки стен, а не про толщину отрезка. Инструмент печатает три вида ответа, и они разные: «прямое совпадение» — смок называет изменённый символ, From 793a6486d8e9594a8aee6d2ae35a0144db8df917 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 10:55:32 +0300 Subject: [PATCH 11/13] =?UTF-8?q?ci(process):=20=D0=BF=D1=80=D0=B8=D0=B2?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B8=20=D0=B2=D0=B5=D1=82=D0=BA=D1=83=20?= =?UTF-8?q?=D0=BA=20dev=20=D0=B4=D0=BE=20=D0=BA=D0=BE=D0=B4-=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D1=8C=D1=8E,=20=D0=B0=20=D0=BD=D0=B5=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D0=BB=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью шло по ветке как есть, слияние делало ребейз: проверенный SHA и слитый SHA были разными коммитами. Текстовое расхождение ловил конфликт, смысловое git склеивал молча — так пришёл регресс #234. Заодно конфликт обнаруживался после сорока минут работы ревьюера, хотя виден до них. Новый шаг для этапа code, сразу после выбора ветки: потомок dev — ничего; отстала и ребейзится — ребейз, push с --force-with-lease, ревью приведённого состояния и запись о ребейзе в промпт (§7.2 требует полного разбора); конфликт — возврат в S6-in-progress без запуска ревью. Issue: #257 User-Visible: no --- .github/workflows/process.yml | 96 ++++++++++++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 1 deletion(-) diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index f78c6f60..80f899eb 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -229,22 +229,110 @@ jobs: 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 + git rebase --abort || true + echo "conflict=true" >> "$GITHUB_OUTPUT" + echo "::warning::ветка $BRANCH не ребейзится на dev без конфликта — ревью не запускается" + 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" + + # Конфликт возвращает задачу автору ДО ревью. Инвариант «после прогона + # метка меняется всегда» при этом держится: возврат в 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 }} + run: | + cat > /tmp/stale.md < S6-in-progress (ревью не запускалось)" + # Зависимости ставятся ПОСЛЕ переключения на ветку задачи: lockfile мог # измениться именно в ней, и установка по копии из dev дала бы не то дерево. - name: Установить зависимости + if: steps.rebase.outputs.conflict != 'true' run: npm ci # Браузер нужен не всякому ревью (см. правило выбора гейтов в промпте), # но когда нужен — качать его заново дороже, чем держать в кэше. - name: Кэш браузеров Playwright id: pw + if: steps.rebase.outputs.conflict != 'true' 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' + if: steps.rebase.outputs.conflict != 'true' && steps.pw.outputs.cache-hit != 'true' # Без --with-deps: системные библиотеки Chromium предустановлены в # образе ubuntu-latest, а apt при промахе кэша съедал минуты из бюджета # ревью и подолгу перебирал недоступное azure-зеркало (#175). Если @@ -254,6 +342,7 @@ jobs: - name: Review id: review + if: steps.rebase.outputs.conflict != 'true' uses: anthropics/claude-code-action@v1 env: # Вне рабочей копии: восстановление дерева ревьюером не должно @@ -277,6 +366,8 @@ jobs: Номер захода нужен для имени документа — два документа с одинаковым номером затёрли бы друг друга. + ${{ steps.rebase.outputs.note }} + **Если цикл не первый — объём разбора по дельте, а не заново** (PROCESS.md §2.9, issue #214). Раньше промпт был одинаковым для всех раундов, и повторный цикл заново выводил продуктовую рамку и @@ -462,6 +553,7 @@ jobs: # Ревьюер пишет только в docs/reviews/. Что именно попадёт в коммит, # решает этот шаг, а не модель: всё остальное откатывается. - name: Опубликовать документ ревью + if: steps.rebase.outputs.conflict != 'true' env: TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} BRANCH: ${{ steps.branch.outputs.name }} @@ -560,6 +652,7 @@ jobs: - name: Решение по вердикту id: decide + if: steps.rebase.outputs.conflict != 'true' env: OUT: ${{ steps.review.outputs.structured_output }} STAGE: ${{ needs.guard.outputs.stage }} @@ -638,6 +731,7 @@ jobs: echo "слито в dev: $(git rev-parse --short HEAD)" - name: Переставить метку + if: steps.rebase.outputs.conflict != 'true' env: # Именно PAT: с GITHUB_TOKEN следующий шаг конвейера не запустится. GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} From 2f9348adf136c55825514fe88ea1b3767902f080 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 13:51:31 +0300 Subject: [PATCH 12/13] ci(process): ask the reviewer about the wall key invariant Issue: #259 User-Visible: no --- .github/workflows/process.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index 80f899eb..45107006 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -455,10 +455,13 @@ jobs: инварианты модели (#254): `npm test` уже гоняет их на всех моделях проекта, а на конкретной конфигурации они проверяются командой `npm run invariants -- --config <экспорт или ответ config/get>`. - Два вопроса, на которые они отвечают, и оба уже стоили продукту - дефектов: не исчезла ли запись толщины (#253) и разрешима ли каждая - ссылка (#244, #252). Если задача меняет геометрию, а инварианты в - отчёте не названы — это непрогнанный гейт, а не мелочь. + Три вопроса, на которые они отвечают, и все три уже стоили + продукту дефектов: не исчезла ли запись толщины (#253), разрешима ли + каждая ссылка (#244, #252) и равен ли ключ записи толщины ключу + решёточного ребра (#258, #259). Последний сравнивает строки без + допусков: сдвиг ключа на один шаг решётки равен допуску первых двух, + поэтому они на нём промахиваются. Если задача меняет геометрию, а + инварианты в отчёте не названы — это непрогнанный гейт, а не мелочь. По необходимости, и «необходимость» определяется diff'ом и AC: - браузерные смоки `demo/smoke_*.mjs` — названные в AC плюс те, From 05622d155b96eb17a013ce143e1153043f159c16 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 03:22:47 +0300 Subject: [PATCH 13/13] fix: merge only the reviewed SHA in the review pipeline (#312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Синхронизация process.yml с dev: шаг слияния сверяет вершину ветки с SHA материала ревью (допустим ровно один doc-коммит публикации поверх) и при расхождении отменяет слияние с возвратом в S6-in-progress. Конвейер исполняется из ветки по умолчанию — правка обязана жить в обеих ветках (process-workflow-sync). Issue: #312 User-Visible: no --- .github/workflows/process.yml | 42 +++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index 45107006..0604ef5a 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -287,6 +287,16 @@ jobs: 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)" + # Конфликт возвращает задачу автору ДО ревью. Инвариант «после прогона # метка меняется всегда» при этом держится: возврат в S6-in-progress — # тоже смена метки, и автор не ждёт впустую. @@ -701,14 +711,42 @@ jobs: 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 - git fetch -q origin dev - git checkout -q -B merge-into-dev "origin/$BRANCH" + git fetch -q origin dev "$BRANCH" + # #312: сливается только проверенный код. Допустимые вершины ветки: + # сам SHA материала либо он же плюс ровно один коммит публикации + # документа ревью (дифф только docs/reviews/). Любой другой коммит — + # ветка уехала после ревью, вердикт к ней не применим: возврат в + # S6-in-progress через merged=false, как при конфликте. + actual=$(git rev-parse "origin/$BRANCH") + reviewed="$MATERIAL_SHA" + fresh=false + if [ "$actual" = "$reviewed" ]; then + fresh=true + elif [ "$(git rev-parse "$actual^" 2>/dev/null)" = "$reviewed" ] \ + && [ -z "$(git diff --name-only "$reviewed" "$actual" -- . ':!docs/reviews')" ]; then + fresh=true + fi + if [ "$fresh" != true ]; then + echo "merged=false" >> "$GITHUB_OUTPUT" + echo "::warning::ветка $BRANCH уехала после проверенного SHA $reviewed (сейчас $actual) — слияние отменено (#312)" + cat > /tmp/stale-verdict.md <