Merge Freeze #
Classification: Confidential (describes release-pipeline source code and infrastructure).
Stops merges to main while a release is being prepared, so what was tested on dev is what ships.
It is driven from Slack and lifts itself once the release tag is created.
The chain #
/merge-freeze on (Slack, admins only)
└─ n8n ─────────────── dispatches merge-freeze.yml (action=freeze)
└─ opens an issue labelled merge-freeze ─── the freeze is now on
merge queue entry
└─ merge-freeze-check.yml ─── "Merge freeze" check fails while the issue is open
/release
└─ n8n merges the Hasami PR (allowed through the freeze)
└─ release-tags.yml ─── creates the tag
└─ lift-merge-freeze ─── calls merge-freeze.yml (action=unfreeze)
└─ re-queues every PR labelled merge-freeze-dropped
The freeze is on while an open issue labelled merge-freeze exists. Nothing else holds the state, so closing that issue by hand lifts the freeze, which also works if n8n or Slack is down.
End-to-end flow #
sequenceDiagram
actor Dev as Release owner
participant Slack
participant GH as GitHub
participant Q as Merge queue
Dev->>Slack: /merge-freeze on
Slack->>GH: Freeze main
GH->>Q: Pull out queued PRs and label them
GH-->>Slack: Merge freeze on
Note over Q: New PRs in the queue are blocked and labelled
Dev->>Slack: /release
Slack->>GH: Merge the Hasami PR
GH->>GH: Create release tag
GH->>GH: Lift the freeze
GH-->>Slack: Merge freeze off
GH->>Q: Re-queue labelled PRs in original order
GH-->>Slack: Re-queued PRs listed
Q->>GH: CI runs, then each PR merges to main
Slack command #
| Command | Effect |
|---|---|
/merge-freeze on [reason] |
Opens the freeze issue and pulls non-exempt PRs out of the queue |
/merge-freeze off [reason] |
Closes every open freeze issue and re-queues dropped PRs |
/merge-freeze status |
Posts whether main is frozen |
The result is posted to the release channel by the workflow, not by n8n.
What the check does #
Merge freeze is a required status check on the main ruleset.
- On a PR it always passes, with a notice while frozen. A PR approved during a freeze needs no re-run once it lifts.
- In the merge queue it fails while frozen, unless the PR is exempt. A PR it rejects drops out
of the queue and gets the
merge-freeze-droppedlabel. - The check runs once, when an entry joins the queue. Entries that passed it before the freeze
would still merge, so
freezealso pulls every non-exempt PR out of the queue and labels itmerge-freeze-dropped. One that fails to dequeue is listed in Slack and may still merge; remove it from the queue by hand. - It reads the PR number from the queue branch (
gh-readonly-queue/main/pr-<N>-<sha>). If it cannot, or the GitHub API errors, it fails rather than letting the merge through. - It judges only that newest PR. That is safe because the
mainqueue uses grouping strategyALLGREENand merges one entry at a time, so every PR passes its own check. Switching toHEADGREENor merging several entries at once would let a frozen PR ride in with an exempt one.
Exemptions #
| PR | Why |
|---|---|
Title Hasami Version Update from hasami-changelog-update |
It is the release |
Head branch hotfix/* |
release-tags.yml refuses to release over an unmerged hotfix, so blocking one would deadlock the release |
Label freeze-exempt |
Break-glass. Use sparingly; the PR still needs its normal review |
The Hasami PR is opened by github-actions, so no PR checks run on it. /release merges it as
tofie-qa, which bypasses the main ruleset. The freeze never blocks it; the exemption only
matters if it ever goes through the queue. Any other ruleset bypasser also skips the freeze.
Lifting the freeze #
- Automatically:
release-tags.ymlrunslift-merge-freezeafter it creates a tag frommain. A hotfix tag (cut fromhotfix/*) leaves the freeze in place. If tag creation fails or the hotfix guard blocks the release, the freeze stays on. - From Slack:
/merge-freeze off. - By hand: close the freeze issue, or run Merge Freeze from the Actions UI.
Closing the issue by hand does not re-queue dropped PRs. Use /merge-freeze off or the Actions
UI for that; running unfreeze when not frozen still re-queues.
Re-queueing dropped PRs #
Every unfreeze re-adds each open PR labelled merge-freeze-dropped to the merge queue and removes
the label. The queue re-runs all required checks, so nothing merges that is no longer green.
PRs go back in the order they were dropped, by when each got the label. freeze labels in-flight
PRs in queue order, so they keep their places; PRs rejected during the freeze follow, oldest first.
A PR whose CI fails is removed as usual, and the ones behind it move up. A PR someone queues by
hand in the seconds before re-queueing finishes can land ahead of them.
- It uses
TOFIE_GITHUB_ACCESS_TOKEN. Events caused byGITHUB_TOKENstart no workflows, so entries it queued would never get their CI. The token needs Contents and Pull requests RW. - A PR that is a draft, retargeted off
main, or fails to enqueue (for example, lost approval) keeps the label and is listed in Slack. The next unfreeze retries it.
Setup #
Ruleset (repo admin, one-off) #
PRs opened before the check existed have no Merge freeze status, and only get one on their next
push. Once required, they cannot be queued until then. So, in this order:
- Merge
merge-freeze-check.ymltomain. - Run Merge Freeze Check from the Actions UI. Its
backfilljob posts a passingMerge freezecheck on the head commit of every open PR tomain. - Add
Merge freeze(GitHub Actions) to the required status checks of themainruleset. - Check the
mainruleset’s merge queue still uses grouping strategyALLGREENand Maximum pull requests to merge1. Re-check this whenever someone edits the queue settings.
If a PR still shows Merge freeze as expected, re-run step 2 or push to it.
n8n #
Same pattern as /release and /hotfix:
- Slack slash command
/merge-freeze→ n8n webhook, HMAC-verified. - Reject callers outside the admin Slack user group.
- Parse
on | off | statusand the optional reason. Mapon→freeze,off→unfreeze. POST /repos/{owner}/{repo}/actions/workflows/merge-freeze.yml/dispatcheswith{"ref": "main", "inputs": {"action": "...", "actor": "<slack user>", "reason": "..."}}. The existing GitHub PAT (Actions: RW) is enough.- Reply ephemerally with “dispatched”. The workflow posts the outcome.