All posts

code-freeze

How to Scope a Code Freeze: Repos, Branches, Environments

Scoping a code freeze means freezing only the repositories, branches, and environments that carry real risk, not the whole org. Here is how to draw the line.

Cody Flynn··9 min read

The scope of a code freeze is the exact set of repositories, branches, and environments it blocks. A well-scoped freeze locks the surfaces that carry real release risk (production services, the branches releases cut from) and leaves everything else moving. A badly scoped freeze locks the whole org, so internal tools, docs, and staging all stall while nobody is any safer.

Most teams get scoping wrong in the same direction: too broad. They freeze "everything" because it feels safer and it is one fewer decision to make. Then engineering grinds to a halt, people route around the freeze to get work done, and the freeze loses credibility for the next time you actually need it. Getting scope right is the difference between a freeze your team respects and a blunt instrument they resent.

Why does over-scoping a freeze backfire?

An over-broad freeze creates pressure that works against the freeze itself. When you block merges to repositories that have nothing to do with the release you are protecting, three things happen.

Work piles up in places it did not need to. A documentation repo, an internal admin tool, and a feature branch that will not ship for a month are all frozen for no risk-reduction benefit. That backlog has to clear when the freeze lifts, and a bigger backlog means a riskier thaw. See managing the deploy backlog after a freeze for why the post-freeze surge is its own hazard.

People stop taking the freeze seriously. If half the frozen repositories obviously did not need freezing, engineers learn that the freeze is theater, and they start asking for overrides on the repositories that genuinely do matter. Every unnecessary block erodes the authority of the necessary ones.

The override queue fills with legitimate requests. A freeze that blocks low-risk work generates a steady stream of reasonable "can you unblock my docs PR" requests, which buries the one override that actually needs scrutiny. A tight scope keeps the override path quiet enough that each request gets real attention.

The goal is not to freeze as much as possible. It is to freeze exactly what a bad change could damage during this window, and nothing else.

What are the three dimensions of freeze scope?

Every freeze scope is defined along three axes. You can set each one independently, and the combination is what determines exactly what gets blocked.

DimensionWhat it controlsTypical narrow scopeTypical broad scope
RepositoryWhich services and codebases are frozenThe 3-4 customer-facing services releasing this cycleEvery repo in the org
BranchWhich branches within those repos are protectedmain (the release branch)All branches, including feature branches
EnvironmentWhich deploy targets are blockedproduction onlyproduction, staging, and preview environments

A freeze is the intersection of the three. "Block merges to main in the payment and checkout services, and block deployments to production" is a precise scope. "Freeze everything" is not a scope, it is the absence of one.

How do you scope by repository?

Scope by repository first, because it is the axis with the widest range and the most waste when you get it wrong. Ask a single question: if a bad change shipped from this repository during the window, would it hurt? If the answer is no, the repository does not belong in the freeze.

Naming each repository by hand does not scale past a handful, and it goes stale the moment someone spins up a new service. Glob patterns solve this. Instead of listing checkout-api, payments-api, and orders-api, you write a pattern that matches the shape of your service names:

  • myorg/*-api — every backend API service, including ones created after the freeze was configured
  • myorg/checkout-* — every repository in the checkout domain (frontend, backend, workers)
  • myorg/payment-service — a single named service, when the scope really is just one

The advantage of a pattern over an explicit list is that it stays correct as the org changes. A new refunds-api created next quarter is automatically covered by myorg/*-api without anyone updating the freeze config. That matters most for recurring freezes, where a stale repository list quietly stops protecting the service you added six months ago.

How do you scope by branch?

Scope by branch to freeze the branch releases cut from, and usually only that branch. For most teams that is main (or master, or a release/* line if you run release branches). Feature branches almost never need freezing, because merging into a feature branch does not ship anything to customers.

This is where a lot of over-scoping hides. Blocking all branches feels thorough, but it stops engineers from doing perfectly safe work: merging cleanup into their own feature branches, integrating a long-running branch, preparing the work that ships after the freeze lifts. None of that reaches production. Freeze the branch that reaches production and leave the rest open, and your team can keep building while the release surface stays locked.

If you run a trunk-based workflow, main is the whole story. If you run release branches, freeze the active release/* branch and main, and leave feature and development branches alone. The rule of thumb: freeze a branch only if a merge into it can, directly or through automation, put code in front of customers.

How do you scope by environment?

Scope by environment to block deployments to the targets that serve customers, which is almost always production and nothing else. Staging, preview, and development environments exist precisely so people can keep testing and validating during a freeze. Freezing them defeats their purpose and gives you nothing.

Environment scoping matters because merge blocking and deployment blocking are separate controls in GitHub. Blocking merges to main stops new code from entering the release branch; blocking deployments to production stops whatever is already in the branch from shipping. A complete freeze usually wants both, but they can be scoped differently. You might block production deployments hard while leaving staging fully open so QA can keep working against real builds. The full mechanics of the two controls are in how to freeze deployments in GitHub.

Environment patterns help here the same way repository patterns do:

  • prod* — matches prod, production, and regional variants like prod-us-east-1
  • production — a single named environment, when that is all you deploy to

A worked example: freezing the payments domain

Say your company runs an end-of-quarter freeze because the last week of the quarter carries most of the revenue risk, and a botched payments deploy then is far more expensive than on a normal Tuesday. You do not need to freeze the marketing site, the internal analytics dashboards, or the mobile app that ships on its own store cadence. You need to freeze the money path.

A scoped freeze for that looks like:

  • Repositories: myorg/payment-* and myorg/checkout-* (the services in the money path, matched by pattern so a new one is covered automatically)
  • Branches: main only (the release branch; feature work continues)
  • Environments: prod* (production and regional production; staging stays open for validation)

Everything outside that intersection keeps moving. The docs team ships docs. The growth team ships experiments to the marketing site. The payments team can still merge into feature branches and deploy to staging. The only thing blocked is the exact action the freeze exists to prevent: shipping a change to production in the money path during the highest-risk week of the quarter. That is a freeze people will respect, because every block in it is obviously justified.

Common scoping mistakes

A few patterns show up again and again:

  • Freezing all environments including staging. This stops QA and validation for no risk reduction. Freeze production; keep staging open.
  • Freezing all branches instead of the release branch. Blocks safe feature work and floods the override queue. Freeze main, not everything.
  • Using explicit repo lists that go stale. A hand-maintained list stops covering services you add later. Use glob patterns tied to naming conventions.
  • One giant org-wide freeze for a single-service risk. If the risk is in payments, freeze payments. Do not halt the whole org to protect one domain.
  • Scoping too narrow and missing a dependency. The opposite failure: freezing checkout-api but not the shared payments-lib it deploys from. Trace what actually ships together and include the whole release unit.

The first four are all over-scoping. The last one is the reason you cannot just default to the narrowest possible scope and stop thinking: scope has to match the real blast radius of a change, which means knowing what ships together.

How NoShip handles freeze scope

NoShip defines scope through rules attached to a freeze window, and each rule matches on repository, branch, and environment patterns. You write a rule like "repositories matching myorg/payment-*, branch main, environments matching prod*," and the freeze applies exactly there. Patterns mean you configure the shape of your services once, and new repositories that match are covered without editing anything.

Because scope lives in rules rather than in per-repository settings you toggle by hand, the same scope can attach to a one-off freeze or a recurring schedule, and it stays consistent every time the window fires. Multiple rules can stack: a quarter-end freeze might carry one rule for the payments domain and a looser one for everything else, each with its own environments. NoShip never requests access to your source code, so it can scope and enforce freezes across merges and deployments without reading what you ship. The concepts guide covers how rules, repositories, and environments map to GitHub's enforcement mechanisms.

Start narrow, widen only with evidence

When you are unsure how wide to draw a freeze, start with the tightest scope that covers the obvious risk and widen only when you can name the specific thing a broader scope would have caught. "Freeze production main in the services that are actually releasing this cycle" is a defensible default for almost any team.

A freeze is a cost you impose on your own engineers to buy stability during a risky window. Scope is how you keep that cost proportional to the risk. Freeze the money path, not the whole company, and the freeze earns the credibility it needs to work the next time you call one. For the broader set of habits that make freezes effective, see the code freeze best practices checklist.

Keep reading