Here is a failure that will cost you an afternoon if you have not seen it before.
You open a pull request. Every required status check goes green. There are no unresolved review threads. The GitHub UI says the branch has no conflicts. You click merge, and:
X Pull request ... is not mergeable: the base branch policy prohibits the merge.
No indication of which policy. No indication of which rule. You push another commit, wait for CI again, try again, and get the identical message. Meanwhile other pull requests in the same repository merge without complaint, so it looks like something specific to this branch, or like GitHub is having a bad day.
It is neither. Most likely one commit in that branch is not signed, and the repository requires signed commits.
Why this is hard to spot
Two things conspire.
The error does not say what is wrong. “The base branch policy prohibits the merge” covers every branch protection rule. Required signatures, unresolved conversations, a stale branch under strict status checks, missing approvals: they all produce that same sentence.
The offending commit is usually not one you wrote. If your local git is configured to sign, all your commits are signed and you will not find the problem by looking at your own work. The usual culprit is a CI workflow that commits back to the branch. Something that regenerates a file and pushes the result.
GitHub Actions’ default git identity does not sign commits. A workflow doing this:
- run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .
git commit -m "chore: regenerate assets"
git push
produces a perfectly valid, completely unsigned commit. It lands in your branch. Branch protection sees it and refuses the merge.
And squash-merging does not rescue you. The signature requirement is evaluated against the commits being merged, not against the single squashed commit that would result.
Diagnosing it, in order
Work from cheap and broad to specific. The first two rule out the other rules that produce the same error.
1. Read the actual protection rules.
gh api repos/<owner>/<repo>/branches/<branch>/protection
Three fields matter here:
required_signatures.enabled, the subject of this postrequired_conversation_resolution.enabled, since an unresolved review thread also blocks yourequired_status_checks.strict, which means the branch must be up to date with the base branch, which has a completely different fix:
gh api -X PUT repos/<owner>/<repo>/pulls/<n>/update-branch
2. Rule out unresolved review threads. The UI can hide these, particularly on outdated diffs:
gh api graphql -f query='{ repository(owner:"<owner>", name:"<repo>") {
pullRequest(number: <n>) { reviewThreads(first: 20) { nodes { isResolved } } } } }'
3. Check every commit in the branch, not just the most recent.
This is the step people skip, and it is the one that finds it. The bad commit is often several back in the history, made by a bot, on a day you were not thinking about signing.
git log --oneline origin/main..<branch>
git log --show-signature -1 <each-sha>
A signed commit shows a gpg: Signature made ... line. The unsigned one shows nothing, and its
author is frequently github-actions[bot]. That is your answer.
Fixing the stuck pull request
The temptation is to rewrite the branch in place and force-push. I would not, particularly on a shared branch or one that has been reviewed. Rebuild it instead, as a new branch with clean history:
git worktree add /tmp/rebuild origin/main --detach
cd /tmp/rebuild
git checkout -b <new-branch>
git checkout <old-branch> -- . # take the full working tree as it stands
# run the build, the tests, the linter, whatever this repo requires
git add -A && git commit -m "..." # one clean commit, signed by your key
git push -u origin <new-branch>
gh pr create ...
gh pr close <old-pr> --comment "Superseded by #<new>, rebuilt with signed history."
You keep the work, you keep the review history on the old pull request, and nothing gets force-pushed. The old pull request stands as a record of what happened, which is worth more than a tidy branch list.
Fixing it properly
Getting the one pull request through is not the fix. The workflow will do it again next week.
Two real options.
Stop auto-committing. Have CI regenerate the artifact, compare it to what is checked in, and fail with instructions if they differ:
- name: Check the rendered output is current
run: |
make render
if ! git diff --exit-code; then
echo "Rendered output is stale. Run 'make render' locally and commit the result."
exit 1
fi
The contributor regenerates locally and commits it with their own signing key. This is the option I would take by default. A bot cannot cleanly sign a commit without a private key living in CI, and putting a signing key in CI to satisfy a rule about commit provenance rather defeats the rule.
Or commit through the API instead of raw git. Commits created via GitHub’s GraphQL
createCommitOnBranch mutation, or the REST Contents API, are signed by GitHub itself and show as
Verified. No key in CI. More moving parts, and worth it only if auto-committing genuinely earns its
place.
The other bug hiding in the same workflow
Worth mentioning because it came from the same habit and cost a second round of confusion.
The workflow that created my unsigned commit also put [skip ci] in the commit message, to stop it
triggering itself in a loop. Reasonable intent, wrong mechanism.
[skip ci] does not skip the workflow that made the commit. It tells GitHub to run nothing for
that push. Every required status check on that commit simply never ran. So on top of the signature
problem, the pull request now had required checks that were not pending and not failed, but absent,
which is its own species of stuck.
If you need a workflow not to retrigger itself, use path filters or a job-level condition. Do not
use [skip ci], unless you genuinely want no CI at all for that commit.
In my case the fix for both was the same: stop the workflow from committing. One change, two bugs gone, which is usually a sign you found the actual root cause rather than a symptom.
The short version
If a pull request shows every check green and still will not merge:
gh api repos/<owner>/<repo>/branches/<branch>/protectionand read the rules rather than guessing- Rule out unresolved threads and a stale branch
git log --show-signatureover every commit in the branch, not just yours- Rebuild the branch cleanly rather than force-pushing over history
- Fix the workflow that made the unsigned commit, or you will be back
The general lesson, if there is one: when an error message is generic, resist the urge to retry it. Three identical failed merges tell you nothing that the first one did not. Go and read the rules the error is referring to, then check your assumptions one at a time. It is slower for about five minutes and much faster after that.