All posts

code-freeze

How to Block Merges to Main During a Code Freeze

Blocking merges to main in GitHub requires a required status check tied to a service that posts failure during a freeze. Here is how to set it up, scale it, and handle emergency overrides.

Cody Flynn··8 min read

Blocking merges to main in GitHub works through required status checks: you configure a branch protection rule that requires a specific check to pass, and a service controls whether that check passes or fails based on whether a freeze is active. When the freeze is on, the check fails and the merge button grays out. When the freeze ends, the check passes and merges open back up.

That is the mechanism. Getting it right takes a few specific steps, and there are a handful of places where the native approach breaks down at scale.

Why branch protection alone is not enough

GitHub has branch protection rules, and it has required status checks. These are different things. A branch protection rule can require pull request reviews, enforce linear history, and restrict who can push directly. But it cannot know whether today is a freeze day.

A required status check is what gives you a dynamic, toggleable freeze. The check is tied to a named context string (for example, freeze/status), and GitHub will not allow a merge until a service posts a success result for that context on the pull request's head commit. If the service posts failure, the PR is blocked. If it never posts anything, the check is treated as pending, and merges are also blocked.

This is the key idea: the branch protection rule is a static configuration that says "this check must pass." The service that posts the check result is what actually implements the freeze logic.

Step-by-step: native GitHub setup

Here is the minimal setup to block merges using a required status check.

1. Choose a status check context string.

Pick something descriptive and unique, like freeze/status or noship/freeze. This is the string your service will post to, and the string you will enter in the branch protection rule. It is case-sensitive.

2. Configure the branch protection rule.

In GitHub: Settings > Branches > Add rule (or edit the rule protecting main).

  • Enable "Require status checks to pass before merging."
  • In the search box, type your context string. GitHub will offer suggestions from recent CI runs; if you have not posted the check yet, you may need to type the full string manually and confirm it.
  • Enable "Require branches to be up to date before merging" if you want PRs to be rebased before the check is evaluated. (Optional, but recommended for consistency.)
  • Enable "Do not allow bypassing the above settings" if you want the freeze to hold for repo admins. Without this, admins can merge freely regardless of the status check.

3. Post the status check from a service.

This is the step most DIY setups skip or get wrong. You need something that posts to the GitHub Commit Status API for every PR that enters the repository. A GitHub App or OAuth token can do this:

curl -X POST \
  -H "Authorization: Bearer $GITHUB_TOKEN" \
  -H "Accept: application/vnd.github+json" \
  https://api.github.com/repos/ORG/REPO/statuses/COMMIT_SHA \
  -d '{"state":"failure","context":"freeze/status","description":"Code freeze active"}'

The catch: you need to post this to every open PR's head commit when the freeze starts, and then re-post success to all of them when it ends. New PRs opened during the freeze also need the check posted at creation time. This means you need a webhook listener, not just a one-time script.

The automation problem

Manual or script-based approaches to block merging in GitHub work for a single freeze event in a single repository. They start to break down when:

  • You have multiple repositories and need to freeze all of them at once.
  • The freeze has a defined start and end time (you need a scheduler, not a human running a script).
  • Someone opens a PR after the freeze starts (new PRs need the status posted too).
  • An engineer needs an emergency bypass, and you need a record of who approved it.

For recurring freezes (every Friday, every December, every major release), writing and maintaining the scheduler and webhook listener becomes its own engineering project.

GitHub Rulesets: the org-wide alternative

GitHub Rulesets (available at the organization level in GitHub Enterprise and GitHub.com for most plans) let you define branch protection rules across all repositories in the org without configuring each one individually. You can target all repositories or a subset by name pattern.

A ruleset for merge blocking looks like:

  1. Organization Settings > Code and automation > Rulesets > New ruleset.
  2. Set the Target to the repositories and branches you want to protect (e.g., main in all repos).
  3. Add the "Require status checks to pass" rule, enter your context string.
  4. Optionally add "Block force pushes" and "Restrict deletions" while you are there.

The ruleset applies to every matching repository automatically, including ones created after you set it up. You still need a service that posts the status check to each repository, but you configure the enforcement once rather than per-repository.

Rulesets also have a bypass list: you can specify people or teams who are exempt. This is how you handle emergency overrides at the org level without turning off the rule entirely.

Handling emergency merges during a freeze

Every merge-blocking approach needs an answer to: "What if we find a critical bug during the freeze?"

The worst answer is "someone with admin rights just bypasses the check." That works technically, but it leaves no record, no second set of eyes, and no way to see post-incident what was merged while the freeze was active.

Better approaches:

MethodWhat it providesWhat it lacks
Admin bypass (GitHub native)Fast, zero setupNo record, no approval, audit-blind
Bypass list in a RulesetNamed exemptions, somewhat traceableStill no approval workflow or audit log
Dual-approval override workflowSecond approver required, full audit trailRequires a tool that implements the workflow

For teams with compliance requirements, the dual-approval pattern matters: one person requests the override, a second approves it, and the merge goes through with both names attached to an audit record. GitHub's native tools do not implement this natively. You need either a GitHub App that manages the override workflow or a process built on top of the API.

Watching for common failure modes

A few things that look like they are working but are not:

The status check was never actually posted. GitHub cannot enforce a check that no service has posted. If your context string has no recent posts, the check will show as "Expected" in the PR and GitHub will block the merge, but not because of an active freeze. Once the service goes down or the webhook breaks, the check silently stops being posted and merges flow freely.

Admins bypass the rule. Unless "Do not allow bypassing the above settings" is checked, organization owners and repository admins can merge through any required check. Most teams check this box and then create a structured bypass path for emergencies rather than leaving the admin override implicit.

Forks are not covered. PRs from forks run CI in the context of the fork's repository. The status check must be posted to the head commit of the PR (in the base repository's context), not the fork's. A webhook listening to pull_request events on the base repo handles this correctly; a webhook listening to push events on the fork does not.

New PRs opened during a freeze have no check. If your service only posts the check when it changes freeze state (freeze starts or ends), any PR opened in between will be stuck pending. The service needs to also listen for pull_request opened events and immediately post the current freeze state to new PRs.

How a dedicated freeze tool handles this

A GitHub App built for freeze management handles all of the above automatically. NoShip is one example: it installs as a GitHub App, requests no access to your source code, and posts the required status check to all open pull requests across all connected repositories when a freeze window opens. It removes the check (posts success) when the freeze ends. Recurring schedules (RRULE) handle Friday freezes and holiday windows without a script. Emergency overrides go through a dual-approval flow and land in a signed audit trail.

The concepts guide has a detailed breakdown of how freeze windows, rules, and status checks map to each other.

If you are setting this up on one repository for a one-time freeze, the native GitHub approach is fine. If you are managing freezes across an org with recurring windows and need an override path that produces an audit record, the manual approach compounds quickly.

What about blocking deployments too?

Blocking merges stops new code from entering main. It does not prevent someone from manually triggering a deployment of an existing commit. A complete freeze covers both surfaces. The how to freeze deployments in GitHub post covers the deployment protection rule side and how the two mechanisms work together.

For most teams, merge blocking is the first control to put in place, because it is simpler and covers the most common risk. Add deployment blocking when you need the full picture.

Keep reading