Status: stable
A merge queue for Gitea and GitHub. Serializes PR merges so your main branch stays green.
There are no bot commands. gitea-mq registers itself as a required status check and hooks into the existing "Merge when checks succeed" / auto-merge button.
When someone clicks that button, gitea-mq notices the pending automerge and
enqueues the PR. For the PR at the head of the queue it creates a temporary
branch (gitea-mq/<pr>) that merges the PR into the current target branch, and
CI runs there. If all required checks pass, gitea-mq marks its own status
success and the forge's automerge completes the merge. If a check fails or
times out, gitea-mq withdraws the merge intent (cancels automerge, removes
the merge label) and posts a comment saying what went wrong.
Alternatively, adding the merge label (default merge-queue, see
GITEA_MQ_MERGE_LABEL) to a PR enqueues it without automerge. On GitHub this
is the way to merge stacked pull requests, which have
no auto-merge: label the topmost PR you want to merge, gitea-mq tests the
whole stack against the stack's base branch and, on green, marks all stack
heads as passed and performs the atomic stack merge itself. Stacked PRs
without the label get a pending gitea-mq status hinting at this workflow.
Removing the label removes the PR from the queue.
By default one PR is tested at a time per target branch (see Batching below
for testing several at once). PRs targeting different
branches get independent queues. If the head-of-queue PR already contains the
current target tip, the merge-branch run is skipped and the PR's own green CI
is accepted, since the merged tree would be identical (disable with
GITEA_MQ_SKIP_QUEUE_IF_UP_TO_DATE=false if your gitea-mq/* CI runs a
larger suite than PR CI).
- Gitea >= 1.22 and/or a GitHub App. With Gitea >= 1.24 (or a Forgejo release
based on it) CI results arrive via the commit-status webhook and idle repos
are only reconciled every
GITEA_MQ_IDLE_POLL_INTERVAL; older versions fall back to polling everyGITEA_MQ_POLL_INTERVAL. - PostgreSQL
- For Gitea/Forgejo: an API token with
repositoryandissueread/write scopes (the PR timeline and comments are served via the issue API)
gitea-mq can manage Gitea repos, GitHub repos, or both from one process. At least one backend must be configured. All configuration is via environment variables.
| Variable | Required | Default | Description |
|---|---|---|---|
GITEA_MQ_GITEA_URL |
gitea | - | Gitea instance URL. Setting this enables the Gitea backend. |
GITEA_MQ_GITEA_TOKEN |
gitea | - | API token with repository and issue read/write scopes |
GITEA_MQ_REPOS |
no | - | Comma-separated owner/repo list of Gitea repos (optional when GITEA_MQ_TOPIC is set) |
GITEA_MQ_TOPIC |
no | - | Discover Gitea repos by topic instead of (or in addition to) a static list |
GITEA_MQ_WEBHOOK_SECRET |
gitea | - | Shared secret for the Gitea webhook HMAC |
GITEA_MQ_GITHUB_APP_ID |
github | - | GitHub App ID. Setting this enables the GitHub backend. |
GITEA_MQ_GITHUB_PRIVATE_KEY / _FILE |
github | - | PEM-encoded App private key, or path to a file containing it |
GITEA_MQ_GITHUB_WEBHOOK_SECRET |
github | - | Webhook secret configured on the GitHub App |
GITEA_MQ_GITHUB_REPOS |
no | - | Comma-separated owner/repo list of GitHub repos to manage in addition to all repos the App is installed on |
GITEA_MQ_GITHUB_POLL_INTERVAL |
no | GITEA_MQ_POLL_INTERVAL |
Override the reconcile poll interval for GitHub (its rate limit is much higher) |
GITEA_MQ_DATABASE_URL |
yes | - | PostgreSQL connection string |
GITEA_MQ_LISTEN_ADDR |
no | :8080 |
HTTP listen address |
GITEA_MQ_WEBHOOK_PATH |
no | /webhook |
Legacy alias for the Gitea webhook endpoint (the canonical path is /webhook/gitea) |
GITEA_MQ_EXTERNAL_URL |
yes | - | URL where Gitea can reach this service (used for webhook auto-setup and commit status target URLs) |
GITEA_MQ_POLL_INTERVAL |
no | 30s |
Reconcile poll interval for repos with active queue work |
GITEA_MQ_IDLE_POLL_INTERVAL |
no | 15m |
Reconcile poll interval for idle repos (no active queue entries). Only used on forges that deliver CI status via webhooks (GitHub, Gitea >= 1.24); there idle repos are driven by webhooks and this periodic poll is just a safety net for deliveries missed while the service was down. Keeping it long bounds forge API usage to the number of repos with live queues rather than all managed repos |
GITEA_MQ_CHECK_TIMEOUT |
no | 1h |
Timeout for required checks |
GITEA_MQ_SKIP_QUEUE_IF_UP_TO_DATE |
no | true |
Skip the merge-branch CI run when a PR is already rebased onto the target branch tip (its own green CI already covers the merged tree) |
GITEA_MQ_REQUIRED_CHECKS |
no | - | Fallback required CI contexts when branch protection has none (comma-separated) |
GITEA_MQ_MERGE_LABEL |
no | merge-queue |
Label that enqueues a PR (stack-aware on GitHub); set to none to disable |
GITEA_MQ_LABEL_PREFIX |
no | mq/ |
Prefix of the queue state labels set on PRs (see Queue labels); set to none to disable |
GITEA_MQ_BATCH_MAX |
no | 1 |
Max PRs tested together as one batch. 1 = batching off (legacy behaviour). 0 = everything currently queued |
GITEA_MQ_BISECT_MAX_STEPS |
no | 0 |
Cap on CI builds spent bisecting one batch. 0 = unlimited |
GITEA_MQ_REFRESH_INTERVAL |
no | 10s |
Dashboard auto-refresh interval |
GITEA_MQ_DISCOVERY_INTERVAL |
no | 5m |
How often to re-scan Gitea topics and GitHub installations |
GITEA_MQ_CACHE_DIR |
no | $XDG_CACHE_HOME/gitea-mq |
Directory for persistent bare git clones used for merge operations; unused repos are removed after 30 days |
GITEA_MQ_LOG_LEVEL |
no | info |
Log level: debug, info, warn, error |
With GITEA_MQ_BATCH_MAX ≠ 1, gitea-mq tests up to N queued PRs at once on a
single gitea-mq/batch/<id> branch. On green it fast-forwards the target
branch itself to the tested SHA (the merged tree is exactly what CI saw). On
red it bisects: split, retest the first half from the current target tip, land
passing halves immediately, eject the failing singleton, then continue with the
pending halves.
Trade-offs and prerequisites:
- gitea-mq must be allowed to push to the protected target branch.
- GitHub: the App is already a bypass actor on the
gitea-mqruleset it creates; no extra setup. - Gitea: add the API token's user to the branch protection's push whitelist. If you forget, every PR in the batch is removed with a comment naming the branch and the user to add.
- GitHub: the App is already a bypass actor on the
- Batched PRs land as merge commits regardless of the repo's configured
merge style. A single PR that already contains the target tip is landed
by fast-forwarding to its head (no merge commit, no extra CI run). Repos that mandate squash/rebase should leave
BATCH_MAX=1. - A semantic conflict between two PRs that bisection puts in different halves
can land both (each half passes alone). This matches bors-ng; keep
BATCH_MAXmodest if it bothers you. - If the forge does not detect a PR as merged within ~10s of the fast-forward,
gitea-mq closes it with a "Merged as
<sha>" comment.
Gitea refuses to merge a PR with an open dependency, but batches land by fast-forwarding the target, which bypasses that check. gitea-mq enforces it:
- A PR with an open dependency is not enqueued. Its
gitea-mqstatus showsBlocked by open dependency #N; its merge intent is kept, so it is queued once the dependency closes. - A queued PR that gains an open dependency is removed, intent kept.
- Right before a batch lands, members are checked again; those that gained a dependency are held back and the rest is re-tested.
The token must be able to read every repository a dependency may point to, as Gitea omits unreadable ones.
There are three ways to tell gitea-mq which repos to manage.
Static list, listing repos explicitly:
GITEA_MQ_REPOS=org/app,org/libTopic discovery: tag repos in Gitea with a topic (e.g. merge-queue) and let
gitea-mq find them. Any repo the API token has admin access to and that carries
the topic gets picked up. Repos are re-discovered periodically, so adding or
removing the topic on a repo is enough.
GITEA_MQ_TOPIC=merge-queueBoth: combine a static list with topic discovery. Explicitly listed repos are always managed regardless of their topics.
GITEA_MQ_REPOS=org/critical-service
GITEA_MQ_TOPIC=merge-queuegitea-mq talks to GitHub as a GitHub App. Use the App creation helper to pre-fill the registration form, or register one manually with:
- Webhook URL:
${GITEA_MQ_EXTERNAL_URL}/webhook/github, secret =GITEA_MQ_GITHUB_WEBHOOK_SECRET - Repository permissions: Checks read & write, Commit statuses read, Contents read & write, Pull requests read & write, Administration read & write, Metadata read
- Subscribed events:
pull_request,check_run,status,installation,installation_repositories
Generate a private key, then set GITEA_MQ_GITHUB_APP_ID and
GITEA_MQ_GITHUB_PRIVATE_KEY_FILE. On startup gitea-mq patches the App's
webhook URL and secret to match GITEA_MQ_EXTERNAL_URL /
GITEA_MQ_GITHUB_WEBHOOK_SECRET, so you can leave those fields blank when
registering the App. Install the App on the orgs/repos you want managed;
gitea-mq picks up every installation automatically. GITEA_MQ_GITHUB_REPOS is
optional and additive: listed repos stay managed even if the installation is
later removed.
gitea-mq keeps one state label on each PR that has merge intent (auto-merge or the merge label), so queue state is visible and filterable in the PR list:
| Label | Meaning |
|---|---|
mq/waiting |
Merge intent, but not queued yet: own CI not green or blocked by an open dependency |
mq/queued |
In the queue, waiting for its turn |
mq/testing |
Being tested on a merge branch (GITEA_MQ_BATCH_MAX=1) |
mq/batch-N |
Member of batch N; the label is deleted when the batch ends |
mq/merging |
Queue passed, waiting for the forge to merge |
Labels are created on first use. This needs write access to issues/PRs: the
Gitea token's issue scope, or on GitHub an App permission that allows
managing labels (Pull requests or Issues write). If
the forge answers 403, labelling is switched off for that repo and the queue
keeps working. On Gitea/Forgejo the labels are created as exclusive scoped
labels, so each transition is a single API call.
On startup, gitea-mq configures each managed repository:
- Gitea: adds
gitea-mqas a required status check to all existing branch protection rules and creates astatuswebhook pointed at the service. - GitHub: enables
allow_auto_mergeand creates agitea-mqrepository ruleset that requires thegitea-mqcheck on the default branch (the App and repo admins are bypass actors). Add further target branches to the ruleset's include list if you queue PRs against more than the default branch.
If the GitHub App lacks the Administration permission, auto-setup is skipped with a warning and the queue still runs against whatever the operator pre-configured.
GITEA_MQ_EXTERNAL_URL is the externally reachable URL of gitea-mq itself
(e.g. https://mq.example.com), not the Gitea URL. It is used for webhook
auto-setup and as the target URL in commit statuses, which links to the
dashboard.
gitea-mq creates temporary branches under gitea-mq/* for CI testing. By
default these are visible to git clients and will be picked up by git fetch.
You can configure Gitea's git to hide them from the ref advertisement.
On NixOS, when services.gitea is enabled on the same host, the module
configures uploadpack.hideRefs for you. To turn that off:
services.gitea-mq.hideRefFromClients = false;Elsewhere, run this as the Gitea system user (the user that owns the Gitea data directory):
git config --global uploadpack.hideRefs refs/heads/gitea-mq/This hides the branches from git fetch and git ls-remote but does not
affect the Gitea web UI, API, or webhook-driven CI. CI systems that need to
check out merge branches can still fetch them with an explicit refspec, e.g.
git fetch origin gitea-mq/123.
Your CI must run on gitea-mq/* branches. For example, in a Woodpecker/Drone
pipeline:
when:
branch:
- main
- gitea-mq/*gitea-mq needs to know which CI checks must pass on the merge branch before it allows a merge. It resolves this in order:
- Branch protection. If the target branch has protection rules with required
status checks, those are used (excluding
gitea-mqitself, to avoid a circular dependency). GITEA_MQ_REQUIRED_CHECKS. If branch protection has no required status checks, or there is no branch protection at all, this comma-separated list is used as a fallback (e.g.ci/woodpecker,lint).- Any single success. If neither is configured, any single passing commit status on the merge branch is enough.
A small web dashboard shows queue status across all managed repos, lets you
drill into individual repos to see queued PRs, and inspect check results per
PR. It auto-refreshes without JavaScript. There is also a /healthz endpoint
for monitoring.
Repo and PR pages live under /repo/{forge}/{owner}/{name} (e.g.
/repo/github/org/app/pr/42). Paths without the forge segment resolve as Gitea
for compatibility with links posted by older versions.
A multi-arch (amd64/arm64) image is published to
ghcr.io/mic92/gitea-mq (:main, :vX.Y.Z). It contains gitea-mq, git and
CA certificates; configuration is via the environment variables above. It needs
a PostgreSQL database; the bare-clone cache lives in /var/cache/gitea-mq.
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: gitea-mq
POSTGRES_PASSWORD: changeme
POSTGRES_DB: gitea-mq
volumes: [db:/var/lib/postgresql/data]
gitea-mq:
image: ghcr.io/mic92/gitea-mq:main
ports: ["8080:8080"]
environment:
GITEA_MQ_DATABASE_URL: postgres://gitea-mq:changeme@db/gitea-mq?sslmode=disable
GITEA_MQ_EXTERNAL_URL: https://mq.example.com
GITEA_MQ_GITEA_URL: https://gitea.example.com
GITEA_MQ_GITEA_TOKEN: ...
GITEA_MQ_WEBHOOK_SECRET: ...
GITEA_MQ_TOPIC: merge-queue
volumes: [cache:/var/cache/gitea-mq]
volumes:
db:
cache:Build it yourself with nix build .#gitea-mq-docker (produces an OCI archive;
load with regctl image import or skopeo copy oci-archive:result docker-daemon:gitea-mq:latest).
{
inputs.gitea-mq.url = "github:Mic92/gitea-mq";
# In your NixOS configuration:
imports = [ inputs.gitea-mq.nixosModules.default ];
services.gitea-mq = {
enable = true;
giteaUrl = "https://gitea.example.com";
giteaTokenFile = "/run/secrets/gitea-mq-token"; # file containing the API token
repos = [ "org/app" "org/lib" ];
databaseUrl = "postgres:///gitea-mq?host=/run/postgresql";
webhookSecretFile = "/run/secrets/gitea-mq-webhook-secret";
externalUrl = "https://mq.example.com";
};
}Real-world deployments:
- Gitea backend: clan-infra, dashboard
- GitHub backend: Mic92/dotfiles, dashboard
| Option | Type | Default | Description |
|---|---|---|---|
enable |
bool | false |
Enable the service |
package |
package | pkgs.gitea-mq |
Package to use |
giteaUrl |
string or null | null |
Gitea instance URL; enables the Gitea backend |
giteaTokenFile |
path | - | File containing the Gitea API token |
webhookSecretFile |
path | - | File containing the Gitea webhook secret |
github.appId |
int or null | null |
GitHub App ID; enables the GitHub backend |
github.privateKeyFile |
path | - | File containing the GitHub App private key (PEM) |
github.webhookSecretFile |
path | - | File containing the GitHub App webhook secret |
github.repos |
list of strings | [] |
GitHub repos in addition to all installations |
github.pollInterval |
string or null | null |
Override poll interval for GitHub |
repos |
list of strings | [] |
Repos to manage (owner/name); optional when topic is set |
topic |
string or null | null |
Discover repos by Gitea topic |
databaseUrl |
string | postgres:///gitea-mq?host=/run/postgresql |
PostgreSQL connection string |
listenAddr |
string | :8080 |
HTTP listen address |
webhookPath |
string | /webhook |
Webhook endpoint path |
externalUrl |
string | - | URL where Gitea can reach this service (for webhook auto-setup and commit status links) |
pollInterval |
string | 30s |
Poll interval |
checkTimeout |
string | 1h |
Check timeout |
skipQueueIfUpToDate |
bool | true |
Skip merge-branch CI for PRs already rebased onto the target tip |
requiredChecks |
list of strings | [] |
Fallback required CI contexts when branch protection has none |
refreshInterval |
string | 10s |
Dashboard refresh interval |
discoveryInterval |
string | 5m |
How often to re-discover repos by topic |
logLevel |
enum | info |
Log level |
# Enter dev shell
nix develop
# Run tests (requires PostgreSQL, provided by the dev shell)
go test ./...
# Build
nix build
# Run NixOS integration test
nix build .#checks.x86_64-linux.nixos-test
# Format
nix fmt