fix(process): a GitHub push refusal is not a stale lease (#705)

merge-candidate treated any push stderr containing "rejected" as a stale
lease. A `! [remote rejected]` from GitHub itself - in #700 the rebased
candidate changed .github/workflows/ and the conveyor token has no workflow
permission (runs 36484993494, 36487044060) - became "the branch moved after
the reviewed material (#312)", and the stderr was never printed, so the
author was sent to look for a commit that did not exist.

classifyPushRefusal now tells three outcomes apart: a stale lease
(`[rejected] (stale info)`, `fetch first`, a server-side lock race) keeps
the old behaviour; GitHub's workflow refusal (PAT, OAuth App, GitHub App,
bot and integration wordings) and any other `[remote rejected]` get their
own outcome, S6-in-progress and a comment naming the reason. The workflow
comment says what to do: the author rebases and pushes, or the owner grants
the permission. The git answer goes to the log and the comment with tokens
and credential URLs cut out; the merge-step failure comment is redacted too.

The rebase guard in _process.yml parses its push refusal with the same code
(`merge-candidate.mjs --push-refusal`): a stale lease is the old error, a
workflow refusal returns the task to S6 without review like a conflict, and
material/reuse/gate skip the rebase that never reached the branch.

Mutant push-refusal-kinds-glued restores the old regex; guard: #705 AC1.

Issue: #705
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-09-30 20:25:08 +00:00
committed by claude[bot]
parent 84ed38e3d5
commit e5c217111c
6 changed files with 584 additions and 21 deletions
+49 -6
View File
@@ -560,11 +560,37 @@ jobs:
# fetch и push автор мог запушить коммит, и слепой --force потерял бы
# его молча. Расхождение lease — падение прогона, а не предупреждение:
# ревью пошло бы по коду, которого на ветке уже нет.
#
# #705: отказ разбирает тот же код, что у слияния кандидата
# (merge-candidate.mjs --push-refusal): ответ git — в журнал без
# токена, исход — одним словом. Отказ GitHub по праву на workflow —
# не сдвинутая ветка: задача возвращается автору без ревью с
# комментарием, как при конфликте. Прочий отказ GitHub — ошибка
# прогона со своей причиной.
push_err="$RUNNER_TEMP/rebase-push.stderr"
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
"HEAD:refs/heads/$BRANCH" 2> "$push_err"; then
refusal="$RUNNER_TEMP/push-refusal.md"
kind=$(node "$tools/scripts/merge-candidate.mjs" --push-refusal="$push_err" \
--branch="$BRANCH" --candidate="$(git rev-parse HEAD)" --stage=rebase \
--run-url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
--comment="$refusal") || kind=unknown
case "$kind" in
stale)
echo "::error::ветка $BRANCH изменилась во время ребейза — прогон прерван, чтобы не потерять коммит автора"
exit 1
;;
workflow)
{ echo 'refused=workflow'; echo "refusal_comment=$refusal"; } >> "$GITHUB_OUTPUT"
echo "::warning::GitHub не принял push ребейза $BRANCH: кандидат меняет workflow-файл, у токена конвейера нет права на workflow — ревью не запускается"
exit 0
;;
*)
echo "::error::GitHub отклонил push ребейза $BRANCH ($kind) — это не изменение ветки автором; ответ git выше"
exit 1
;;
esac
fi
# #539: ссылка на стороне GitHub доезжает не мгновенно, а гейт ниже
# просит `workflow_dispatch` ПО ИМЕНИ ВЕТКИ — SHA туда передать
@@ -598,7 +624,7 @@ jobs:
# это заметить и отказаться, а не молча увезти в dev непроверенный код.
- name: Зафиксировать SHA материала ревью
id: material
if: steps.rebase.outputs.conflict != 'true'
if: steps.rebase.outputs.conflict != 'true' && steps.rebase.outputs.refused == ''
env:
NUM: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
@@ -651,7 +677,7 @@ jobs:
# теле issue, которого в дереве нет.
- name: "Зелёный вердикт прошлого захода применим без ревью (#499)"
id: reuse
if: steps.rebase.outputs.conflict != 'true' && needs.guard.outputs.stage == 'code'
if: steps.rebase.outputs.conflict != 'true' && steps.rebase.outputs.refused == '' && needs.guard.outputs.stage == 'code'
env:
NUM: ${{ github.event.issue.number }}
# Правка ТЗ между раундами обязана отменять повторное применение
@@ -702,6 +728,23 @@ jobs:
--add-label S6-in-progress --remove-label S7-code-review
echo "S7-code-review -> S6-in-progress (ревью не запускалось)"
# #705: GitHub не принял push приведённой ветки — кандидат меняет
# workflow-файл, а у токена конвейера нет права на workflow. Ветку никто
# не двигал, и ревью по неопубликованному ребейзу шло бы по коду, которого
# на ветке нет. Возврат автору без ревью, как при конфликте: цикл не
# израсходован, метка меняется. Текст комментария собрал разбор отказа.
- name: "Push ребейза отклонён по праву на workflow — вернуть автору без ревью (#705)"
if: steps.rebase.outputs.refused == 'workflow'
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
NUM: ${{ github.event.issue.number }}
COMMENT: ${{ steps.rebase.outputs.refusal_comment }}
run: |
gh issue comment "$NUM" --repo "${{ github.repository }}" --body-file "$COMMENT"
gh issue edit "$NUM" --repo "${{ github.repository }}" \
--add-label S6-in-progress --remove-label S7-code-review
echo "S7-code-review -> S6-in-progress (ревью не запускалось: push отклонён по праву на workflow)"
# До ревью конвейер запускает Validate на материале и ждёт его — всегда
# лёгкий: мутантов в разработке нет (#709), весь реестр — ночью. Лёгкому годится и
# завершённый push-прогон на том же SHA: dispatch повторил бы его. Красный или
@@ -712,7 +755,7 @@ jobs:
# слияние само дожидается Validate на кандидате.
- name: Validate на материале
id: gate
if: steps.rebase.outputs.conflict != 'true'
if: steps.rebase.outputs.conflict != 'true' && steps.rebase.outputs.refused == ''
env:
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
STAGE: ${{ needs.guard.outputs.stage }}