Git, CI/CD & GitOpsXXII · Force PushForcePush
Branch protection and force-push — server-side enforcement
What you'll learn
- Identify which branch-protection rule prevents force-pushes and what it does on the server
- Distinguish the three enforcement tiers: deny all, allow admins, allow specific bypass actors
- Configure branch protection to require `--force-with-lease` for any rewrite of a shared branch
- Recognise when a force-push is structurally impossible (trunk, signed-tag-anchored branch) versus merely forbidden by policy
Prerequisites
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
Server-side branch protection is the structural complement to the
client-side --force-with-lease guard rail. The lease is the
client’s last-line check that catches tip divergence; branch
protection is the server’s structural check that catches the
attempt to rewrite a protected branch in the first place. This
lesson covers the protection rules, the enforcement tiers, and the
bypass actors.
What branch protection does
A protected branch is one for which the remote’s pre-receive hook refuses any push that would rewrite history. The hook runs on the server before the ref-update protocol accepts the change; a rejected push never reaches the object store.
git push --force origin main
# To git@github.com:acme/iac.git
# ! [remote rejected] main -> main (protected branch, cannot force push)
# error: failed to push some refs to 'git@github.com:acme/iac.git'
The protected branch, cannot force push rejection is server-
side: it does not depend on the client’s --force-with-lease
flag, the client’s fetch state, or the client’s branch
configuration. It depends only on the protection rule configured
on the remote for that branch.
flowchart LR
PUSH["git push --force origin main"] --> HOOK["pre-receive hook on remote"]
HOOK --> RULE{"branch protection rule"}
RULE -->|"protected: deny"| REJ["push refused before ref-update"]
RULE -->|"protected: admins"| ADMIN{"is actor in admin role?"}
RULE -->|"unprotected"| ACC["push accepted"]
ADMIN -->|"yes"| ACC
ADMIN -->|"no"| REJ
The hook runs before the ref-update protocol; the rejection message comes from the server, not from the client. The local remote-tracking ref is unchanged.
The three enforcement tiers
Branch protection rules can be configured with three enforcement tiers, depending on the host:
- Deny all. Any push that would rewrite history (force-push
or non-fast-forward) is refused for every actor, including
administrators. This is the strongest tier and is the only
one that makes force-pushes structurally impossible; even an
admin cannot force-push a
deny allbranch. - Allow admins. Force-pushes are refused for non-admin actors but allowed for users in the repository administrator role. The branch is forbidden-by-policy for engineers but permitted-by-role for admins.
- Allow specific bypass actors. Force-pushes are refused for most actors but allowed for a specific list (e.g. a release engineer with a dedicated account, a CI service account). This is the most granular tier and is appropriate for branches where a known actor has a legitimate need to rewrite.
# GitHub: configure deny-all on the default branch
gh api -X PUT /repos/acme/iac/branches/main/protection \
-H "Accept: application/vnd.github+json" \
-f required_status_checks='{"strict":true,"contexts":["ci"]}' \
-f enforce_admins=true \
-f required_pull_request_reviews='{"required_approving_review_count":2}' \
-f restrictions=null \
-f allow_force_pushes=false
The configuration is host-specific but the semantics are consistent: the rule is enforced server-side, the rejection message comes from the hook, and the local ref is unchanged.
When force-push is structurally impossible
A force-push is structurally impossible (cannot be done by any actor through the normal protocol) when:
- The branch is protected with
deny allenforcement, AND - The pre-receive hook is the only enforcement path, AND
- The branch has no other bypass mechanism (e.g. no
maintainerrole, no override flag, no UI bypass).
A force-push is merely forbidden-by-policy when:
- The branch is protected with
allow adminsenforcement (an admin can still force-push), OR - The protection rule can be removed or weakened through the repository’s settings (which an admin can do), OR
- A bypass actor exists in the configuration.
The structural difference matters for incident response. A denied push leaves no trace on the remote; a forced push leaves a reflog entry that records the actor, the old tip, and the new tip. If the remote is a GitHub-equivalent with audit logging, the actor’s identity is in the events feed regardless of the push outcome.
Bypass actors and emergency overrides
Some hosts support a bypass or maintainer role that can
override protection rules. The semantic is the same as allow admins but the implementation is separate: the bypass actor’s
account is named in the configuration, the bypass action is
logged, and the bypass can be revoked by removing the actor.
# GitLab: configure a specific bypass actor
curl --header "PRIVATE-TOKEN: $TOKEN" \
--data "name=main" \
--data "allowed_to_push[]=access_level:40" \
--data "allowed_to_merge[]=access_level:40" \
--data "allow_force_push=true" \
--data "code_owner_approval_required=true" \
"https://gitlab.com/api/v4/projects/$ID/protected_branches"
The discipline for bypass actors: the actor’s account should be dedicated to the bypass role (not a personal account), the bypass should require a second-person approval, and every bypass should be reviewed in the post-incident process. A bypass actor that is routinely used by a single engineer is a single point of failure and a compliance risk.
Production discipline
- Protect every trunk branch with
deny all. The trunk ismain(ormaster, or whatever the team uses). No actor, including admins, should be able to force-push it. - Protect every release branch with
allow adminsonly. Release branches sometimes need to be rewritten for credential rotation or post-release cleanup; the rewrite is permitted but logged. - Never configure a personal account as a bypass actor. The bypass account should be a service account with a dedicated purpose, not a personal account that can rotate with the engineer.
- Log every change to the protection configuration. The repository’s audit feed should record who changed the rule, when, and what the change was. A silent config change is a security incident.
- Test the protection rule before trusting it. The first force-push attempt to a protected branch from an admin account should be a deliberate test, not an accident. Engineers should know what the rejection message looks like so they recognise it when they see it.
Cross-course references
- Git, CI/CD & GitOps - Part XI (Rebasing) lesson 06 covers the client-side discipline of avoiding force-pushes; this lesson is the server-side complement.
- Git, CI/CD & GitOps - Part XXIV (ServerSideHooks) covers pre-receive hooks in detail; the protection rule is implemented as a pre-receive hook.
- CI/CD Pipeline Patterns - Part VIII (BranchPolicies) discusses branch protection as a deploy gate.
Quiz
Knowledge check · 4 questions
Q1. What does branch protection do on the server when an engineer tries to force-push a protected branch?
Q2. Configuring branch protection with `allow admins` enforcement makes a force-push of the protected branch structurally impossible.
Q3. Distinguish a `deny all` protected branch from an `allow admins` protected branch in terms of who can force-push and what audit trail is produced.
Q4. Diagnose why an engineer cannot force-push a branch that they expect to be unprotected, and recommend the recovery procedure.
An engineer is trying to `git push --force origin main` after rotating a credential and amending a commit. The push is refused with `[remote rejected] main -> main (protected branch, cannot force push)`. The engineer believes the `main` branch is not protected in their repository. They want to understand why the push was refused and what to do next.
Passing score: 75%. Answers are checked in this browser.