Skip to main content
RunBook Academy

Git, CI/CD & GitOpsXXII · Force PushForcePush

Branch protection and force-push — server-side enforcement

Advanced⏱ ~22 mingit

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

Not yet marked complete on this device.

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 all branch.
  • 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 all enforcement, AND
  • The pre-receive hook is the only enforcement path, AND
  • The branch has no other bypass mechanism (e.g. no maintainer role, no override flag, no UI bypass).

A force-push is merely forbidden-by-policy when:

  • The branch is protected with allow admins enforcement (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

  1. Protect every trunk branch with deny all. The trunk is main (or master, or whatever the team uses). No actor, including admins, should be able to force-push it.
  2. Protect every release branch with allow admins only. Release branches sometimes need to be rewritten for credential rotation or post-release cleanup; the rewrite is permitted but logged.
  3. 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.
  4. 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.
  5. 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

  1. Q1. What does branch protection do on the server when an engineer tries to force-push a protected branch?

  2. Q2. Configuring branch protection with `allow admins` enforcement makes a force-push of the protected branch structurally impossible.

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

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