Tooling · Automation
The Automation
That Was Waiting on Itself
A Dependabot auto-merge workflow had been running for weeks, and every PR it ever merged had genuinely squeaked through — not because the workflow worked, but because a race condition happened to resolve in its favor every single time. The real bug was structural: one of its own steps was waiting for a check run that could only ever finish after that same step did.
A backlog of dependency-update PRs had built up, and the auto-merge workflow meant to clear them looked, on paper, like it had been doing its job — some PRs in its history really had merged on their own. Digging into why the backlog existed at all turned up something worse than a workflow that occasionally failed. It was a workflow that had never once succeeded for the reason it was supposed to.
A Job Waiting on Itself
The merge step called gh pr checks --watch — wait until every check on the PR goes green, then approve and merge. The auto-merge job is itself one of that PR's checks. So the step was watching a list of checks that included its own still-running self, waiting for a condition that could only become true after the step watching for it had already finished. A deadlock with exactly one participant, and the only thing that ever ended it was GitHub Actions' own six-hour job timeout, silently, on every single run.
Every PR that had ever "auto-merged" before this had done so by winning a timing race against that six-hour clock — some other event nudging the PR closed before the deadlocked job noticed. Not the workflow working. The workflow losing a race in a direction nobody minded.
The fix replaced the self-referential wait with a poll: fetch gh pr checks --json name,bucket, explicitly filter out the check named auto-merge — the job's own name, excluded from the list of things it waits on — and cap the whole job at 45 minutes so a real hang fails loudly instead of burning six hours to find out.
The Second Bug, Waiting Right Behind the First
The first real end-to-end run of the corrected workflow made it all the way to the last step and died there. gh pr review --approve failed: GitHub Actions is not permitted to approve pull requests, a repository setting, not a bug in the call itself. The script's strict-mode shell treated that failure as fatal and aborted one line before the merge that was the entire point of the run.
The approve call had never been doing anything useful in the first place — a workflow that isn't gated on a required-review branch rule has nothing riding on an approval existing at all. It came out rather than getting worked around.
What 502s Leave Behind
Clearing the backlog meant retrying gh pr merge --squash against a run of GitHub 502s, and a few PRs came out of that in a state the command's own exit code didn't reveal: the squash commit had actually landed on main, but the pull request itself stayed open — in one case with a second, duplicate squash commit from a retry that ran again against a request that had actually succeeded the first time. A merge command's reported failure and its real effect on the repository had quietly stopped being the same fact.
The recovery was a single comment on every affected PR: @dependabot recreate. Dependabot closes the ones whose change is already sitting on main and force-pushes a fresh branch for the ones that genuinely still need to merge — cheaper and more reliable than trying to reconstruct, PR by PR, which category each one actually belonged to.
Each Failing PR Was Failing for Its Own Reason
Underneath the workflow-level bugs, several individual PRs were failing on their own unrelated merits, not because of anything wrong with the automation around them: a Go end-to-end test computing a statement period from an unnormalized date, which only broke when CI happened to run on the 31st of a month; a ruff 0.16 upgrade whose new formatter reached into every Python code block embedded in the docs, not just the source files; three Kotlin Gradle plugins that had to move to 2.4.10 together as one atomic bump, because any one of them landing alone broke the build. None of them were the automation's fault, and none of them would have been fixed by the automation running correctly — they needed to actually be looked at.
What "It Works Now" Actually Looked Like
The real test of whether any of this held up wasn't a green run watched live. It was noticing, later, in the middle of something unrelated, that one more routine dependency bump had opened, passed its checks, and merged itself, with nobody watching it happen at all.
Any workflow that gates a merge on "all checks are green" and is itself one of those checks has this failure waiting inside it — the deadlock only resolves by accident, via some outside timeout or unrelated event, never because the logic actually completes. Worth auditing for in any CI setup that self-approves or self-merges, not just Dependabot automation specifically.
A worked example of the corrected workflow — self-check excluded, no approve step