ci: публикация ревью-дока не имеет права трогать ничего, кроме документа

28.08 коммит bb2919f уехал в dev с тридцатью файлами вместо одного markdown:
откатил отревьюженную реализацию #359, вернул старые чанки, оставил в dist/
двойной набор. dev держал откаченное дерево три часа. Сообщение коммита было
невинным, и от рутины инцидент отличался только диффом.

Механизм воспроизведён локально, а не предположен. `git checkout -- .`
восстанавливает рабочее дерево ИЗ ИНДЕКСА, `git clean -fd` убирает
неотслеживаемое — ни то, ни другое индекс не трогает. Ревьюер работает с Bash и,
проверяя «умеет ли тест падать», вполне может сделать git add; всё оставшееся у
него в индексе прежняя уборка сохраняла, и следующий git commit забирал это
вместе с документом.

Отсюда три рубежа, каждый закрывает свой отрезок пути.

База: reset --hard на свежий origin/$target снимает и индекс, и дерево разом.
Терять нечего — документ приезжает из RUNNER_TEMP, а не из рабочей копии.
Индексируется ровно один путь, а не каталог.

Индекс: перед коммитом дифф проверяется allowlist'ом docs/reviews/.

Диапазон: перед КАЖДЫМ push проверяется origin/$target...HEAD — то есть то, что
пуш добавит в ветку. Проверок две, потому что push делается из двух мест, и
второй путь срабатывает ровно тогда, когда dev ушёл вперёд — в тех самых
условиях, при которых случился bb2919f.

Пустой дифф — тоже отказ: публиковать нечего означает, что документа нет, а
прежняя редакция шага выходила тут с нулём и оставляла вердикт без артефакта
(#171). Сравнение по префиксу каталога, а не подстрокой: docs/reviews-old и
docs/reviewsx разрешёнными не считаются. Форс-пуш отсутствует и закреплён тестом.

Четыре мутанта проверены руками, два добавлены в реестр. Пятый — «убрать одну из
двух проверок диапазона» — сначала выжил: тест требовал наличия, а не количества.
Тест усилен до подсчёта, мутант убит.

Issue: #365
User-Visible: no
This commit is contained in:
Claude
2026-08-29 10:38:36 +03:00
parent cbf9318efd
commit 23d6681d3e
4 changed files with 229 additions and 12 deletions
+39 -12
View File
@@ -642,14 +642,31 @@ 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).
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 -- .` плюс
@@ -661,11 +678,12 @@ jobs:
cp "$SOURCE" "$doc"
echo "документ взят из $SOURCE ($(wc -c < "$doc") байт)"
else
# Совместимость: ревьюер мог написать по старому пути, если промпт
# ещё не обновился в этой ветке.
echo "::warning::$SOURCE не найден — ищу документ в рабочей копии"
echo "::warning::$SOURCE не найден — документа для публикации нет"
fi
git add docs/reviews 2>/dev/null || true
# Индексируется ровно один путь, а не каталог: `git add docs/reviews`
# забрал бы всё, что там окажется, а после reset там не должно быть
# ничего постороннего — но полагаться на «не должно» здесь нельзя.
git add -- "$doc" 2>/dev/null || true
if git diff --cached --quiet; then
# Пустая рабочая копия — ещё не провал: ревьюер иногда коммитит
# документ сам, своим app-токеном мимо этого шага (CODE-REVIEW-150-r1,
@@ -683,6 +701,8 @@ jobs:
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 - <<EOF
@@ -691,6 +711,10 @@ jobs:
Issue: #$NUM
User-Visible: no
EOF
# Второй рубеж, и он главный: что пуш ДОБАВИТ в целевую ветку. Первый
# судит намерение шага, этот — результат, а расходились они именно
# тогда, когда база оказывалась не той.
git diff --name-only "origin/$target...HEAD" | node scripts/review-doc-guard.mjs
# Публикация в dev идёт из детачнутого состояния поверх ветки задачи
# либо dev, поэтому push нужен с явным перебазированием при гонке:
# dev мог уйти вперёд, пока шло ревью — оно длится до 45 минут.
@@ -705,6 +729,9 @@ jobs:
echo "::error::документ ревью не удалось опубликовать в $target: конфликт (#171)"
exit 1
fi
# После ребейза набор путей другой — проверяется заново. Форс здесь
# запрещён и не появляется: ветка двигается только вперёд.
git diff --name-only "origin/$target...HEAD" | node scripts/review-doc-guard.mjs
git push -q "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \
"HEAD:$target"
fi