entry-46
Protect the branch where the pushes actually land
My GitHub ruleset rejected my own push mirror with GH013, because a personal account cannot exempt a mirror identity. Branch protection belongs on the self-hosted side, where the pushes actually land, and it is free there.
revisions rev. 1 first set 2026-10-23 last revised 2026-10-23 5 min read
Update (2026-10-19): The policy counts below describe the October 12 configuration; the rotation follow-up records the separate credential work.
I moved all 21 repos off GitHub and onto a self-hosted git server, the way I described in my earlier post on the migration. GitHub stayed as a push mirror: I push to the self-hosted side, a mirror task copies each repository out to GitHub as a backup and a public face, and GitHub is a copy rather than the place where work happens.
Then one repository’s mirror stopped taking new commits. The task log had a rejection from GitHub: GH013, changes must be made through a pull request. Not an authentication failure, a policy decision made about a pusher that was my own mirror.
Six public repositories carried that rule, so every commit I made to any of them would have failed to mirror, and nothing was watching for it. I found it because I re-synced one repository by hand and read the task output instead of assuming it succeeded.
Three guesses, all wrong
My first guess was credentials, because a mirror that stops working looks like a token that expired. The task authenticated fine. An authentication problem is a different error, and this was the host saying it knew who was pushing and did not care.
My second guess was a transient API flap. Re-running the sync produced the same rejection with the same reason, and another repository showed the identical text. Rejections that are stable across repositories are policy, not weather.
My third guess felt obviously correct and was wrong in a specific way. I assumed I could exempt the mirror identity from the rule the way a bypass list works. Bypass lists are built from entities that exist in an organization: teams, roles, an organization owner. My repositories are owned by a personal account, where none of those exist, and a personal account cannot exempt a collaborator from a ruleset. There was no setting to change, which was the real wall.
The rule was in the wrong place
The setting was a ruleset requiring changes through a pull request, with older classic branch protection doing the same job on some repositories. Both are rules about who may push.
Here is what reframed the problem. Requirements-through-pull-request rules are free on public repositories and need a paid plan on private ones. So the free half of that feature sat on exactly the repositories where a mirror pushes and a second writer does not, and the half I would have to pay for was the half that would have helped. Private repositories never had a ruleset at all.
The protection was not wrong in kind, it was wrong in location: it guarded the copy and ignored the source.
Moving protection to the side where pushes land
Every push lands on the self-hosted side now, so that is where protection belongs. I enabled branch protection on the default branch of all 21 repositories, with two settings that match how each group is used:
- The 14 private repositories allow fast-forward pushes, so normal commits go through while force-pushes and branch deletion are rejected. These are working repositories, and I want the history guardrail rather than a gate.
- The 7 public repositories accept no direct pushes. Changes land by merging a pull request, which is the workflow I want on the repositories that exist as a public record.
Then I removed the GitHub rules. I saved each as JSON in the runbook directory first, deleted the six rulesets, and re-synced the repository that had failed. The rejection was gone, the mirror advanced, and the branches matched on both sides. The cost is nothing, and it covers the private repositories too, which is the part I would have had to pay for on the other host.
One honest gap: I did not run a deliberate force-push test against the new protection. The API for this version has no separate force-push field, and protected branches reject non-fast-forward pushes by default, but a default is not a tested guarantee. That test is on the list.
The companion lesson: rebase onto the new base, do not push over it
Three commits I had made the night before were still local and had been built on an older base, and the shared branch had moved on.
The tempting fix is to force-push the local branch, because that makes the divergence disappear. It also deletes the other side’s work, and here the other work was newer and deliberate. Two files conflicted, and both conflicts were semantic: my change and the newer change touched the same structures for different reasons, so the resolution was to keep both behaviours and check they still made sense together.
So I rebased, read both sides of each conflict instead of taking one wholesale, pushed a branch, opened a pull request through the same merge path the other repositories now use, let continuous integration run, and merged. The shared branch ended up with both changes intact.
The rules I keep now
Protect the branch where pushes land. If a host only receives copies, do not put a gate on the copy. A mirror that needs an exemption is a signal that the rule is on the wrong machine.
When a mirror stops, read the mirror’s own task log before touching credentials. Not allowed and not authenticated look similar in a one line summary and are completely different problems.
After any change to branch protection, prove the copy advanced: compare the head commit on both sides on a repository you just pushed to.
Rebase onto the current base and resolve conflicts by understanding both sides. Force-push only when I can write down why the other side’s commits are superseded. If I cannot write that sentence, the force-push is hiding work I have not read.
Keep the pull request requirement, but make it cheap enough that nobody wants to remove it. A small shell helper now pushes a branch and opens the pull request on the self-hosted side in one step. Which is a quiet reminder of the point: the mirror is a copy, the work happens somewhere else, and that is where the protection belongs.
Related posts: Forgejo as primary, GitHub as a mirror.