Evaluate a merge queue for Forgejo (gitea-mq vs shunt); no native support in v16 #25

Open
opened 2026-07-27 12:31:07 +00:00 by ullrich · 0 comments
Owner

ci.yml carries the note "no merge_group trigger (Forgejo has no merge queue)", and CLAUDE.md still instructs gh pr merge --merge --auto against a GitHub merge queue that no longer exists. This is the gap.

Forgejo has no native merge queue, and none is scheduled

Not in v16.0 — its Actions work went into artifact REST endpoints, log downloads, and prioritized workflow runs. There is an open upstream feature request, forgejo/forgejo#5102 "feat: Add a Merge Queue/Merge Train", but nothing landed. So this is third-party or nothing.

What Forgejo does have is auto-merge ("Merge when checks succeed"). Both candidates build on it rather than replacing it — they insert themselves as a required status check and let Forgejo perform the actual merge.

The two candidates

gitea-mq shunt
Forges Gitea 1.22+, Forgejo, GitHub Forgejo (validated v15.x + Codeberg v16), Gitea ≥1.22 unvalidated
Maturity 278 commits, 24 stars, "status: stable" 53 commits, 2 stars, v0.8.0, self-described "young"
In production clan.lol, thalheim.io none stated
Default strategy one PR at a time; optional bors-style batching batch by default, bisect on failure
CI cost 1 run per PR (serial) N PRs = 1 run on the happy path
State PostgreSQL required SQLite file or Postgres
Deploy binary, Docker, NixOS module binary, Docker, Compose, Helm, Kustomize
Opt-in per-repo config repo topic tag, auto-configures branch protection

Recommendation: gitea-mq. "Stable" with real deployments beats a 2-star v0.8 for something that gates every merge to main, and its serial default matches how this repo actually works. Its weaker Forgejo story is the one real concern — it advertises "Forgejo (based on Gitea 1.24+)" while we run v16.0.1, and Forgejo stopped tracking Gitea version numbers long ago, so that claim needs verifying against our instance rather than trusting it. shunt is the more interesting design (bisection is the right answer to batch failures) and explicitly validated on v16, so it is the fallback if gitea-mq's API assumptions break.

The thing to check before either one: signed commits

We enabled require_signed_commits on main. Both tools work by pushing temporary branches (gitea-mq/<pr>, mq/<base>/staging-*) and then letting Forgejo's auto-merge land the result. That should be fine, because:

  • the staging branches are not protected, so unsigned bot pushes there are allowed;
  • the merge commit onto main is created by Forgejo itself, signed with the instance key we configured.

But gitea-mq's documented limitation — "batched PRs merge as commits regardless of repository merge style preferences" — means a batched merge may not take the same path. Any adoption needs one throwaway PR to prove a queued merge actually satisfies require_signed_commits before we route real work through it. Getting this wrong would wedge merges entirely.

Both also need a bot account with repo admin rights (a Forgejo/Gitea API limitation, not a design choice). On a single-maintainer instance that is a real expansion of the trust surface for modest benefit — worth naming rather than glossing.

Do we actually need this?

Honestly, not urgently. A merge queue exists to stop PRs that pass individually from breaking main together — semantic conflicts that no per-PR check can catch. With branch protection already requiring CI / * to pass and typically 1–3 concurrent PRs by one maintainer, that window is narrow.

The argument for doing it anyway is that our checks are slow (~10 min) and PRs here are frequently stacked, which is exactly the shape where a stale-base merge slips through: PR B is tested against a main that PR A has since moved. We have not been bitten by it yet.

Suggested sequencing:

  1. Fix CLAUDE.md first — it documents a workflow (gh pr merge --merge --auto, "merge queue") that cannot work. That is actively misleading today and costs nothing to correct. Covered by the broader GitHub-reference cleanup.
  2. Then evaluate gitea-mq against a scratch repo on the instance, including the signed-commit check above.
  3. Adopt only if step 2 is clean.

Note also ci.yml gates the security scan on github.actor != 'dependabot[bot]' and cd-staging.yml on dependabot/github_actions/ head refs — both dead conditions post-Renovate, unrelated to the queue but in the same neighbourhood.

Sources

`ci.yml` carries the note *"no `merge_group` trigger (Forgejo has no merge queue)"*, and `CLAUDE.md` still instructs `gh pr merge --merge --auto` against a GitHub merge queue that no longer exists. This is the gap. ## Forgejo has no native merge queue, and none is scheduled Not in v16.0 — its Actions work went into artifact REST endpoints, log downloads, and prioritized workflow runs. There is an open upstream feature request, [forgejo/forgejo#5102 "feat: Add a Merge Queue/Merge Train"](https://codeberg.org/forgejo/forgejo/issues/5102), but nothing landed. So this is third-party or nothing. What Forgejo *does* have is **auto-merge** ("Merge when checks succeed"). Both candidates build on it rather than replacing it — they insert themselves as a required status check and let Forgejo perform the actual merge. ## The two candidates | | [gitea-mq](https://github.com/Mic92/gitea-mq) | [shunt](https://github.com/rbtr/shunt) | |---|---|---| | Forges | Gitea 1.22+, Forgejo, GitHub | Forgejo (**validated v15.x + Codeberg v16**), Gitea ≥1.22 unvalidated | | Maturity | 278 commits, 24 stars, **"status: stable"** | 53 commits, 2 stars, v0.8.0, self-described "young" | | In production | clan.lol, thalheim.io | none stated | | Default strategy | one PR at a time; optional bors-style batching | batch by default, **bisect on failure** | | CI cost | 1 run per PR (serial) | N PRs = 1 run on the happy path | | State | PostgreSQL required | SQLite file *or* Postgres | | Deploy | binary, Docker, NixOS module | binary, Docker, Compose, Helm, Kustomize | | Opt-in | per-repo config | repo **topic tag**, auto-configures branch protection | **Recommendation: gitea-mq.** "Stable" with real deployments beats a 2-star v0.8 for something that gates every merge to `main`, and its serial default matches how this repo actually works. Its weaker Forgejo story is the one real concern — it advertises "Forgejo (based on Gitea 1.24+)" while we run **v16.0.1**, and Forgejo stopped tracking Gitea version numbers long ago, so that claim needs verifying against our instance rather than trusting it. shunt is the more interesting design (bisection is the right answer to batch failures) and explicitly validated on v16, so it is the fallback if gitea-mq's API assumptions break. ## The thing to check before either one: signed commits We enabled **`require_signed_commits` on `main`**. Both tools work by pushing temporary branches (`gitea-mq/<pr>`, `mq/<base>/staging-*`) and then letting Forgejo's auto-merge land the result. That should be fine, because: - the staging branches are not protected, so unsigned bot pushes there are allowed; - the merge commit onto `main` is created by **Forgejo itself**, signed with the instance key we configured. But gitea-mq's documented limitation — *"batched PRs merge as commits regardless of repository merge style preferences"* — means a batched merge may not take the same path. **Any adoption needs one throwaway PR to prove a queued merge actually satisfies `require_signed_commits` before we route real work through it.** Getting this wrong would wedge merges entirely. Both also need a bot account with repo admin rights (a Forgejo/Gitea API limitation, not a design choice). On a single-maintainer instance that is a real expansion of the trust surface for modest benefit — worth naming rather than glossing. ## Do we actually need this? Honestly, not urgently. A merge queue exists to stop PRs that pass **individually** from breaking `main` **together** — semantic conflicts that no per-PR check can catch. With branch protection already requiring `CI / *` to pass and typically 1–3 concurrent PRs by one maintainer, that window is narrow. The argument *for* doing it anyway is that our checks are slow (~10 min) and PRs here are frequently stacked, which is exactly the shape where a stale-base merge slips through: PR B is tested against a `main` that PR A has since moved. We have not been bitten by it yet. Suggested sequencing: 1. Fix `CLAUDE.md` first — it documents a workflow (`gh pr merge --merge --auto`, "merge queue") that cannot work. That is actively misleading today and costs nothing to correct. Covered by the broader GitHub-reference cleanup. 2. Then evaluate gitea-mq against a scratch repo on the instance, including the signed-commit check above. 3. Adopt only if step 2 is clean. Note also `ci.yml` gates the security scan on `github.actor != 'dependabot[bot]'` and `cd-staging.yml` on `dependabot/github_actions/` head refs — both dead conditions post-Renovate, unrelated to the queue but in the same neighbourhood. ## Sources - [forgejo/forgejo#5102 — feat: Add a Merge Queue/Merge Train](https://codeberg.org/forgejo/forgejo/issues/5102) - [Forgejo v16.0 release notes](https://forgejo.org/2026-07-release-v16-0/) - [Mic92/gitea-mq](https://github.com/Mic92/gitea-mq) - [rbtr/shunt](https://github.com/rbtr/shunt)
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
trails-cool/trails#25
No description provided.