Freezing deploys with branch protection Jump to heading
Deploy freezes happen for good reasons: a peak trading weekend, a major customer migration, an end-of-year period when on-call cover is thin. The usual implementation is a message in the team channel asking people not to merge, which works until someone misses it and their merge auto-deploys on the busiest day of the year. A freeze is far more reliable when the repository enforces it: a required check that fails while a freeze is active, or a ruleset that blocks merges to the deploy branch, with a documented route for approved emergency fixes. This page sets up an enforceable freeze, an exception path, announcements and a clean lift, within environment and deployment branches.
When to use this approach Jump to heading
- Merges to
mainor a deploy branch deploy automatically, and you need to stop that for a period. - Past freezes were announced but broken by merges from people who missed the message.
- Emergency fixes must still be possible during a freeze, with approval.
- You run scheduled releases, as in running a scheduled release train.
Step 1 — Decide what the freeze blocks Jump to heading
Be explicit. A freeze can block merges to the deploy branch, block deployments while allowing merges, or both. Blocking deployments only lets work continue integrating on main while production stays still, which reduces the pile-up when the freeze lifts.
Step 2 — Store the freeze state in one place Jump to heading
Keep the freeze window in a file in the repository or a repository variable, so checks, deploy jobs and announcements read the same value.
# Repository variables read by workflows
gh variable set DEPLOY_FREEZE_START --body "2026-11-26T18:00:00Z"
gh variable set DEPLOY_FREEZE_END --body "2026-12-01T08:00:00Z"
gh variable set DEPLOY_FREEZE_REASON --body "Peak trading weekend" Step 3 — Enforce it with a required check or deploy guard Jump to heading
Add a job that fails during the window. Make it a required status check to block merges, or a step at the start of the deploy job to block deployments.
jobs:
freeze-check:
runs-on: ubuntu-latest
steps:
- name: Fail during a deploy freeze unless an exception is approved
env:
START: ${{ vars.DEPLOY_FREEZE_START }}
END: ${{ vars.DEPLOY_FREEZE_END }}
REASON: ${{ vars.DEPLOY_FREEZE_REASON }}
EXCEPTION: ${{ contains(github.event.pull_request.labels.*.name, 'freeze-exception') }}
run: |
now=$(date -u +%s); s=$(date -u -d "$START" +%s 2>/dev/null || echo 0); e=$(date -u -d "$END" +%s 2>/dev/null || echo 0)
if [ "$now" -ge "$s" ] && [ "$now" -lt "$e" ] && [ "$EXCEPTION" != true ]; then
echo "::error::Deploy freeze until $END: $REASON. Add the freeze-exception label with approval to proceed."
exit 1
fi Re-run the check when the label changes by adding labeled and unlabeled to the pull request trigger types. The required-check mechanics are covered in merge queues and required checks.
Step 4 — Define the exception path Jump to heading
Emergency fixes must still be possible. Require an approval from a named group to apply the exception label, and record why.
# Who may apply the label: restrict via a ruleset requiring review from @org/release-managers
gh pr edit 1234 --add-label freeze-exception
gh pr comment 1234 --body "Freeze exception approved by @release-manager: fixes checkout failures for EU customers (INC-4821)." On deploy-job freezes, use a protected environment with required reviewers instead: the deploy waits for an approval from the release managers.
Step 5 — Announce the freeze from the same data Jump to heading
Generate the announcement and a banner from the stored window, so the message never disagrees with the enforcement.
start=$(gh variable get DEPLOY_FREEZE_START); end=$(gh variable get DEPLOY_FREEZE_END); why=$(gh variable get DEPLOY_FREEZE_REASON)
printf 'Deploy freeze: %s → %s (UTC). Reason: %s.\nEmergency fixes: add the freeze-exception label and get release-manager approval.\n' "$start" "$end" "$why" Post it a week ahead, the day before, and when the freeze starts.
Step 6 — Lift the freeze deliberately Jump to heading
When the freeze ends, do not release everything at once. Deploy the accumulated changes in small batches with monitoring between them, starting with low-risk ones.
git log --oneline --first-parent "$(git describe --tags --abbrev=0 --match 'deploy-*')..origin/main" | wc -l # changes waiting
gh variable delete DEPLOY_FREEZE_START; gh variable delete DEPLOY_FREEZE_END Rolling back a problem batch is covered in rolling back a deployment with Git.
Step 7 — Review exceptions afterwards Jump to heading
After each freeze, list the exceptions granted and why. Many exceptions for the same kind of change point to something that should be fixed before the next freeze.
gh pr list --state merged --label freeze-exception --search "merged:2026-11-26..2026-12-01" --json number,title,mergedAt \
--jq '.[] | "\(.mergedAt[:10]) #\(.number) \(.title)"' Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Why not just disable the deploy workflow? Jump to heading
Disabling it blocks emergency fixes too, and depends on someone remembering to re-enable it. A guard with an exception path keeps the deploy available for what matters.
Should hotfix branches bypass the freeze? Jump to heading
They should take the exception path like everything else. A separate bypass for hotfix branches tends to become the way everything ships during freezes.
How long should a freeze be? Jump to heading
As short as the risk allows. Long freezes build large backlogs, and large backlogs make the lift itself risky.
Related Jump to heading
- Environment and Deployment Branches — the parent topic.
- Promoting a Release from Staging to Production — the deploy path being frozen.
- Scheduled Workflows and Cron Triggers — automating freeze reminders.
- Hotfix Branches That Do Not Drift from Main — fixes made during a freeze.