merge_pr.py: a successful merge reports `refuse: a GitHub call failed` when the repo's auto-delete beats `gh --delete-branch`, skipping the #389 UI gate nf-6hwc ← Beads

closed priority 2 task unassigned

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.

What happened

``` 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 |

Mechanism — a race the repo setting guarantees

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.

What got skipped

Everything after :447:

That 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.

Direction, not a prescription

- 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.

Dependencies

None.

Comments

No comments.

Add a comment