Merge Freeze

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-dropped label.
  • The check runs once, when an entry joins the queue. Entries that passed it before the freeze would still merge, so freeze also pulls every non-exempt PR out of the queue and labels it merge-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 main queue uses grouping strategy ALLGREEN and merges one entry at a time, so every PR passes its own check. Switching to HEADGREEN or 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.yml runs lift-merge-freeze after it creates a tag from main. A hotfix tag (cut from hotfix/*) 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 by GITHUB_TOKEN start 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:

  1. Merge merge-freeze-check.yml to main.
  2. Run Merge Freeze Check from the Actions UI. Its backfill job posts a passing Merge freeze check on the head commit of every open PR to main.
  3. Add Merge freeze (GitHub Actions) to the required status checks of the main ruleset.
  4. Check the main ruleset’s merge queue still uses grouping strategy ALLGREEN and Maximum pull requests to merge 1. 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:

  1. Slack slash command /merge-freeze → n8n webhook, HMAC-verified.
  2. Reject callers outside the admin Slack user group.
  3. Parse on | off | status and the optional reason. Map on → freeze, off → unfreeze.
  4. POST /repos/{owner}/{repo}/actions/workflows/merge-freeze.yml/dispatches with {"ref": "main", "inputs": {"action": "...", "actor": "<slack user>", "reason": "..."}}. The existing GitHub PAT (Actions: RW) is enough.
  5. Reply ephemerally with “dispatched”. The workflow posts the outcome.