Enforce Branch Protection Rules via GitHub API
Require pull request reviews and status checks on the main branch, enforce linear history, and audit the rules with a single API call that fails CI on drift.
Task
Apply branch protection rules to main and verify they cannot be bypassed by admins or force pushes.
When to use this
A hotfix pushed straight to main last week reverted a peer-reviewed change. The team wants main protected, but the repo has six branches and nobody remembers which rules are already in place.
Prerequisites
- A GitHub token with repo:administration scope
- The gh CLI 2.40 or later
- A list of required status check contexts
Steps
1. Inspect the current protection on main so you know what to change without clobbering existing rules.
gh api repos/:owner/:repo/branches/main/protectionExpected output: A JSON object showing required_status_checks, enforce_admins, required_pull_request_reviews, and restrictions.
2. Apply protection that requires two reviews, dismisses stale reviews, and enforces linear history.
gh api -X PUT repos/:owner/:repo/branches/main/protection \
-f required_pull_request_reviews[required_approving_reviews_count]=2 \
-f required_pull_request_reviews[dismiss_stale_reviews]=true \
-f required_linear_history=true \
-f enforce_admins=true
Expected output: The API returns 200 and the protection object reflects the new review and history constraints.
3. Add the required status checks that must pass before a merge is allowed.
gh api -X PUT repos/:owner/:repo/branches/main/protection/required_status_checks -f contexts[]=ci/lint -f contexts[]=ci/testExpected output: A PR with a failing ci/lint check shows a blocked merge button until the check passes.
Troubleshooting
Problem
enforce_admins cannot be set on a free private repo
Solution
Branch protection for admins requires a paid plan; document the gap and gate admin pushes through a codeowners review instead.
Problem
A status check context name is wrong
Solution
List the most recent check runs with gh run list and copy the exact context string; a typo silently skips the check.
Limitations
- Protection rules do not apply to commits made through the API by a repo admin unless enforce_admins is true.
- A force push to an unprotected branch can still rewrite history that later merges into main.
Protect main, then audit
A branch protection rule is only useful if it cannot be silently removed. Apply the rule through the API, then add a CI job that re-reads the protection and fails if a required check or review count has drifted.
gh api repos/:owner/:repo/branches/main/protection
Make the audit repeatable
Store the expected protection as JSON in the repo. A scheduled job diffs the live protection against the file and opens an issue on drift. This catches the case where an admin disables a rule for a hotfix and forgets to re-enable it.
Expected outcome
A force push to main is rejected, a PR without two approvals is blocked, and a failing status check prevents merge. The audit job stays green while the rules match the file and fails the moment someone relaxes them.