Twinned from https://github.com/cfsh/ninefold/issues/631 by tools/beads/import_github.py's reconcile pass.
**merge_pr.py printed `refuse: a GitHub call failed, so the merge cannot be
gated safely` and exited 1 — for a merge that had already succeeded, after the
gate had already passed.** The post-merge steps, including the #389 UI gate,
were skipped in silence.
Measured on nf-dev-sg merging #553 today. Filed unclaimed.
``` presubmit PASSED against head 0e1b02f refuse: a GitHub call failed, so the merge cannot be gated safely. command failed (exit 1): gh pr merge 553 -R cfsh/ninefold --squash --delete-branch failed to delete remote branch chore/publish-header: HTTP 404: Reference does not exist ```
Exit 1. The merge had landed anyway:
| | |
|---|---|
| PR #553 | MERGED 2026-08-06T12:00:44Z, squash commit d49a920 |
| #545 (Closes) | auto-closed 12:00:45Z |
| origin/main | d49a920 |
| remote branch | already gone |
cfsh/ninefold has delete_branch_on_merge=true. So `gh pr merge --squash
--delete-branch` does two things that both try to delete the branch: GitHub
deletes it on merge, and gh then issues its own delete. When GitHub wins,
gh's delete gets HTTP 404 and gh exits non-zero — *after* the merge.
merge_pr.py:447 calls gh.merge(...) through the strict runner, so that
non-zero exit raises ProcError, which main's handler at :536 catches and
turns into the refusal message.
⚠️ The :525-535 comment explaining that broad catch is right — #470 F4
added it deliberately so a flaky or unauthorized gh speaks this file's
vocabulary instead of a traceback. The gap is that ProcError **cannot
distinguish a pre-merge failure from a post-merge one**, and only the first kind
is a refusal.
Everything after :447:
git checkout main / git fetch / git reset --hard origin/mainmerged #N and synced main lineprobe_gate() — the #389 UI gateThat last one is the real cost. probe_gate's own docstring says why it exists:
*"main was the one branch never verified — f5 gets swept several times an
hour, main got swept never. #387 sat red on main for days and was found by
accident."* A false refusal reinstates exactly that hole, quietly, on a merge
that looks failed. I ran it by hand afterwards; nothing would have if I had
trusted the exit code.
And the message is precisely wrong on this path. The merge *was* gated —
presubmit PASSED against head 0e1b02f is printed one line above — and it
cannot be un-landed by an exit code. probe_gate()'s exit status is already
ignored for this exact reason (:505-509: *"a regression report must not make a
SUCCEEDED merge look like a failed one to the poll loop, which keys off this
script's status"*). The same argument applies one level up, where it is
currently violated.
It is intermittent, which is why merges from this box have been clean before: it only fires when GitHub's auto-delete wins the race.
- Drop --delete-branch. The repo already deletes on merge, so the flag can
only ever be redundant-or-404. Cheapest fix; removes the race rather than
handling it.
- Or: on a non-zero gh pr merge, re-read the PR and if state=MERGED,
continue to the post-merge steps and report the delete failure as a note. That
also covers other post-merge gh flakiness.
- Either way probe_gate() should run whenever the merge landed. Consider
running it from a finally-shaped path keyed on merge state, not on gh's
exit code.
None.
No comments.