Merge holds
A merge hold stops merges into one branch, for a reason, until a time. Use one for a release train cut, an incident, or a migration that must not race other changes. Release trains place and release their own holds (source: train), owned by the train’s owner.
A hold covers one of these:
- every repository of the organisation;
- a list of repositories (up to 50);
- one repository.
It always applies to one branch, main unless you name another.
How a hold stops a merge
fremforge posts a commit status, fremforge/hold, on the head commit of every open pull request into a held branch:
| Status | Meaning |
|---|---|
pending | A hold covers this pull request. The description names the owner, the expiry and the reason. |
success | No hold covers it, or an admin let this pull request through (see Admin override). |
On a branch where fremforge/hold is a required check, a pending hold stops every way of merging:
- The merge button and the API merge endpoint refuse with “Not all required status checks successful”.
- Auto-merge waits. When the hold is released, the status turns
successand any pull request armed to merge then merges. A pull request armed during a freeze lands after it. - The merge queue keeps the pull request out of a landing group. If the hold lasts longer than the queue’s check timeout, the queue parks the pull request and puts it back once, when its checks are green again.
Pushing to a pull request under a hold, or opening a new one, gets the status within seconds. A reconcile job repairs any status that is missing, stale or was posted by someone else every 5 minutes.
Holds fail closed. If fremforge cannot post the status, a new pull request into a held-enforced branch waits until it can.
In the console
Org owners manage holds at Build & deploy → Merge holds (https://frem.sh/<org>/_admin/merge-holds): the live holds with their owner, expiry and where the merge button waits, buttons to release, extend or let one pull request through, a form to set a hold, the enforcement switches, and the holds that recently ended. Everything on the page is also in the API below.
Turn on enforcement
Requiring fremforge/hold is opt-in, per repository or for the whole organisation, per branch. Until you opt in, holds are listed and read by tooling, but the merge button does not wait for them.
curl -X PUT https://frem.sh/_app/api/v1/orgs/<org>/merge-holds/enforcement \
-H "Authorization: Bearer $FREMFORGE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"repo": "*", "branch": "main"}'repo is a repository name, or * for every repository, including ones created later. The token needs policy:write.
When you opt in, fremforge first posts fremforge/hold on every open pull request into the branch. Only then does it add the check to the branch’s protection rule. No pull request ever waits on a check nothing has posted.
The response lists:
required_on: repositories where the check is now required.unprotected: repositories whose branch has no protection rule. fremforge does not create one; add a rule and the reconcile job requires the check within 5 minutes.failed: repositories whose pull requests could not be read. Their rule is left alone and the job retries.
To opt out, send the same body with DELETE. fremforge removes the check wherever no other opt-in still covers the repository.
Set a hold
curl -X POST https://frem.sh/_app/api/v1/orgs/<org>/merge-holds \
-H "Authorization: Bearer $FREMFORGE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"repos": ["platform", "infrastructure-azure"],
"reason": "Train 42 cut: no merges until the tst probe passes",
"duration_minutes": 120
}'| Field | Required | Notes |
|---|---|---|
reason | yes | One line, up to 500 characters. Shown in the status on every held pull request. |
expires_at or duration_minutes | exactly one | At least 5 minutes and at most 7 days ahead. expires_at is ISO 8601 with a zone. |
repos | no | Omit to hold every repository. |
branch | no | One branch name, no wildcards. Default main. |
owner | only for a token that acts for no person | A member of the organisation. |
The token needs deployments:write, the scope that also pauses deployments. Reading holds needs deployments:read.
Owner
Every hold belongs to a person. When you, or an agent acting on your behalf, set a hold, you are its owner. A token that acts for no person, such as a CI token, must name owner.
Expiry and the alarm
No hold blocks forever. When a hold reaches its expiry, fremforge releases it within 5 minutes, records merge_hold.expired in the audit log, and emails the owner. If merges must stay held, extend it before then:
curl -X PATCH https://frem.sh/_app/api/v1/orgs/<org>/merge-holds/<id> \
-H "Authorization: Bearer $FREMFORGE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"duration_minutes": 60}'List and release holds
# Live holds, and where enforcement is on
curl https://frem.sh/_app/api/v1/orgs/<org>/merge-holds \
-H "Authorization: Bearer $FREMFORGE_TOKEN"
# Release one
curl -X POST https://frem.sh/_app/api/v1/orgs/<org>/merge-holds/<id>/release \
-H "Authorization: Bearer $FREMFORGE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"note": "Train 42 is out"}'Add ?state=all to the list to include released holds. Held pull requests turn success within seconds of a release.
Placing and releasing a hold also appear on the event feed as hold.placed and hold.released, one event per repository the hold covers.
AI assistants connected through the fremforge MCP server can list holds with the list_merge_holds tool.
Admin override
Two overrides exist, and both are recorded in the audit log:
- Release someone else’s hold. Any org owner can release any hold. When the owner releases it, the audit log records
merge_hold.released. When anyone else does, it recordsmerge_hold.override. - Let one pull request through. For a hotfix during a freeze, exempt a single pull request. Its status turns
success, and the hold stays for everything else. A reason is required. The audit log recordsmerge_hold.exempted.
curl -X POST https://frem.sh/_app/api/v1/orgs/<org>/merge-holds/<id>/exemptions \
-H "Authorization: Bearer $FREMFORGE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"repo": "platform", "pr_number": 812, "reason": "INC-7 hotfix"}'A repository admin can still merge by hand where the branch rule does not apply to admins. That is the break-glass path, and Forgejo records it like any merge.
Release freezes from an issue
If your team freezes a repository by opening an issue labelled release-freeze, that keeps working. In a repository where enforcement is on, fremforge turns each open release-freeze issue into a hold on that repository:
- the issue’s author owns it;
- its reason is the issue title;
- closing the issue, or removing the label, releases it within seconds.
So the merge button now honours the freeze too. A freeze has no end time of its own, so its hold runs for 24 hours at a time: while the issue is open, fremforge renews the hold when it expires and emails the owner that the freeze is still on.
What a hold does not do
- It does not stop direct pushes to the branch. Use branch protection to require pull requests.
- It does not stop deployments. Pause a deployment environment for that.
- Anyone with write access to a repository can post a commit status under any name. fremforge puts
fremforge/holdback and recordsmerge_hold.status_foreignin the audit log when someone else posts it. That closes the gap within seconds to minutes, not instantly.
Audit events
| Event | When |
|---|---|
merge_hold.created | A hold was set, including a hold mirrored from a release-freeze issue. |
merge_hold.updated | Its owner extended it or changed the reason. |
merge_hold.released | Its owner released it, or its release-freeze issue closed. |
merge_hold.override | Someone other than the owner released or changed it. |
merge_hold.exempted | One pull request was let through. |
merge_hold.expired | It reached its expiry and was released. |
merge_hold.renewed | A release-freeze hold expired while its issue was still open. |
merge_hold.enforcement_enabled / merge_hold.enforcement_disabled | Enforcement was turned on or off. |
merge_hold.status_foreign | Someone else posted fremforge/hold, and fremforge put it back. |