GitHub Action that merges a pull-request when an event triggers the workflow. Written in Rust, runs as a Docker container action — no Node.js runtime needed on the runner.
Marketplace: https://github.com/marketplace/actions/pull-request-merge
| Name | Required | Default | Description |
|---|---|---|---|
github-token |
yes | — | GitHub token used to call the REST API. Usually ${{ secrets.GITHUB_TOKEN }}. |
number |
yes | — | Pull-request number to merge. |
merge-method |
no | merge |
One of merge, squash, rebase, fast-forward, fast-forward_or_merge. |
allowed-usernames-regex |
no | ^.*$ |
Regex the triggering actor (github.actor) must match. Skips the merge otherwise. |
filter-label |
no | (empty) | Regex matched against PR labels. When set, the merge is skipped unless a label matches, and the first matching label is removed after a successful merge. |
merge-title |
no | (empty) | Commit title used by the merge/squash/rebase API. Ignored for fast-forward. |
merge-message |
no | (empty) | Commit body used by the merge/squash/rebase API. Ignored for fast-forward. |
merge/squash/rebase— callPUT /repos/{owner}/{repo}/pulls/{n}/merge.fast-forward— callPATCH /repos/{owner}/{repo}/git/refs/heads/{base}to move the base branch to the PR's head SHA. This is a true fast-forward: the base ref must already be an ancestor of the head, otherwise GitHub refuses the update. No merge commit is created.fast-forward_or_merge— attempt a fast-forward first; if the base branch is not an ancestor of the head (i.e. the fast-forward fails), fall back to a regularmerge.
The workflow's GITHUB_TOKEN needs write access to both pull requests and
repository contents. Declare this explicitly at the top of your workflow:
permissions:
contents: write # merge / fast-forward the base branch
pull-requests: write # perform the merge and remove the filter labelname: auto-merge
on:
pull_request:
types: [labeled]
permissions:
contents: write
pull-requests: write
jobs:
merge:
runs-on: ubuntu-latest
steps:
- uses: sudo-bot/action-pull-request-merge@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
number: ${{ github.event.pull_request.number }}
filter-label: merge-it
allowed-usernames-regex: ^williamdes$- uses: sudo-bot/action-pull-request-merge@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
number: ${{ github.event.pull_request.number }}
merge-method: squash
merge-title: "${{ github.event.pull_request.title }} (#${{ github.event.pull_request.number }})"
merge-message: ${{ github.event.pull_request.body }}- uses: sudo-bot/action-pull-request-merge@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
number: ${{ github.event.pull_request.number }}
merge-method: fast-forward
filter-label: merge-it- uses: sudo-bot/action-pull-request-merge@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
number: ${{ github.event.pull_request.number }}
allowed-usernames-regex: ^(williamdes|alice|bob)$
filter-label: ^(merge-it|ship-it)$The action performs these checks, in order, and logs what it's doing using
standard ::warning:: / ::error:: workflow commands:
- If
github.actordoes not matchallowed-usernames-regex, the step emits a warning and exits successfully (the workflow is not failed). - The PR is fetched. If its state is
closed, the step warns and exits. - If
filter-labelis set and no label on the PR matches, the step warns and exits. - The merge (or fast-forward) is performed.
- If
filter-labelwas set, the matching label is removed. A failure here produces a warning but does not fail the step.
Any network or API failure during step 4 does fail the step.
The action also runs on Gitea Actions.
Detection is automatic: when the runner sets GITEA_ACTIONS=true (or
GITHUB_API_URL ends in /api/v1), the action talks to Gitea's REST API
instead of GitHub's. From the workflow author's perspective, the inputs and
the step usage are identical.
Under the hood, the Gitea-specific differences are handled for you:
-
Merge — Gitea uses
POST /repos/{o}/{r}/pulls/{n}/merge(notPUT) with theDo/MergeTitleField/MergeMessageField/head_commit_idbody shape. -
Label removal — Gitea's
DELETEendpoint requires the numeric label id, so the action looks up the issue's labels first and resolves the configured name to its id. -
Fast-forward — Gitea's
git/refsAPI is read-only, so a fast-forward cannot usePATCH /git/refs/{ref}the way GitHub does (Gitea responds405 Method Not Allowed). The action instead drives the fast-forward through the merge endpoint withDo: "fast-forward-only", which landed in Gitea 1.22.Version requirements by
merge-method:merge-method Gitea ≥ 1.22 Gitea < 1.22 merge✓ ✓ (any Gitea ≥ 1.17) squash✓ ✓ rebase✓ ✓ fast-forward_or_merge✓ ✓ — FF attempt 422s on the unknown Dovalue, then falls back.fast-forward✓ ✗ — hard-fails. Use fast-forward_or_merge, or upgrade Gitea.
The action itself is portable, but workflow triggers are not always identical between the two forges. Two gotchas to know about:
-
pull_requestevent payload — On GitHub thelabeled/unlabeledaction fills ingithub.event.label.nameat the top level; on Gitea the comparable event ispull_request_label(with actionlabel_updated/label_cleared) and the label is only present insidegithub.event.pull_request.labels[]. A gate that readsgithub.event.label.nametherefore evaluates tonullon Gitea and the job is silently skipped.Portable form: test against the labels array, which is populated on both forges:
if: contains(github.event.pull_request.labels.*.name, '/merge')
Trade-off: the job re-runs harmlessly when any label changes while the gating label is still attached (the action's idempotency checks skip closed PRs and missing labels, so this is not a correctness issue — just an extra evaluation).
-
Container image registry — Gitea runners must be able to pull
ghcr.io/sudo-bot/action-pull-request-merge:latest. If your runner only has access to a private registry, pre-pull or mirror the image there.
name: auto-merge
on:
pull_request:
types: [labeled, opened, synchronize] # GitHub
pull_request_label: # Gitea
types: [label_updated]
permissions:
contents: write
pull-requests: write
jobs:
merge:
runs-on: ubuntu-latest
# Works on both forges: read the labels array, never the top-level
# `event.label.name`.
if: contains(github.event.pull_request.labels.*.name, 'merge-it')
steps:
- uses: sudo-bot/action-pull-request-merge@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
number: ${{ github.event.pull_request.number }}
filter-label: merge-it
allowed-usernames-regex: ^williamdes$