From 4c6c0cd041259e2e42ed11f1a70bc04aeec50313 Mon Sep 17 00:00:00 2001 From: "Luong Vo (Lucas)" Date: Mon, 25 May 2026 12:16:46 +0700 Subject: [PATCH 1/3] Init "labeled" merge-queue action --- merge_queue/README.md | 142 +++++++++++ merge_queue/action.yml | 238 +++++++++++++++++++ merge_queue/sample/workflows/merge_queue.yml | 26 ++ 3 files changed, 406 insertions(+) create mode 100644 merge_queue/README.md create mode 100644 merge_queue/action.yml create mode 100644 merge_queue/sample/workflows/merge_queue.yml diff --git a/merge_queue/README.md b/merge_queue/README.md new file mode 100644 index 0000000..0b44082 --- /dev/null +++ b/merge_queue/README.md @@ -0,0 +1,142 @@ +# merge-queue + +A simple "labeled" merge queue for GitHub Actions, alternative to [GitHub's native merge queue](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue). + +No GitHub Enterprise required. No external services. Just a workflow. + +## What it does + +Label a PR with `mergeme` and merge-queue takes over: + +1. **Rebases** the PR onto the latest base branch +2. **Waits** for all status checks to pass on the rebased code +3. **Squash merges** into the base branch and deletes the source branch +4. **Dequeues** with a PR comment explaining what went wrong if anything fails + +Multiple PRs labeled at the same time? They all get processed. When a job starts, it scans for every open PR with the label and works through them in order — so even if several PRs are labeled while one is already being processed, they'll all be picked up by the time the queue drains. + +## Quick start + +Create `.github/workflows/merge-queue.yml` in your repo: + +```yaml +name: Merge Queue + +on: + pull_request: + types: [labeled] + +concurrency: + group: merge-queue + cancel-in-progress: false + +jobs: + merge: + if: github.event.label.name == 'mergeme' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + checks: read + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: nimblehq/github-actions-workflows/merge_queue@v1.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} +``` + +That's it. Label a PR with `mergeme` to try it out. + +## How the queue works + +When triggered, the action scans for all open PRs carrying the label and processes them serially — oldest first. The `concurrency` block ensures only one job runs at a time, so a second trigger that arrives while the first job is busy will wait and then drain whatever remains in the queue when it starts. + +``` +PR #1 labeled ─┐ +PR #2 labeled ─┤──> job picks up #1, #2, #3 in order ──> all merged +PR #3 labeled ─┘ +``` + +Each PR rebases onto the base branch at the moment it's processed, which includes all previously merged PRs. This is the key property of a merge queue — no PR merges without being tested against the current state of the target branch. + +## What happens on failure + +merge-queue removes the `mergeme` label and leaves a comment on the PR explaining what went wrong: + +| Failure | What merge-queue does | +| ------------------------ | ---------------------------------------------------- | +| Merge conflicts | Removes from queue, comments to resolve conflicts | +| Rebase fails | Removes from queue, comments to rebase manually | +| Status checks fail | Removes from queue, comments to fix and re-label | +| Timeout (default 30 min) | Removes from queue, comments to re-label | +| Squash merge rejected | Removes from queue, comments about branch protection | + +To retry, fix the issue and add the `mergeme` label again. + +## Inputs + +| Input | Description | Default | +| --------------- | -------------------------------------------------- | --------------------- | +| `github-token` | GitHub token with write access to contents and PRs | `${{ github.token }}` | +| `label` | Label that adds a PR to the queue | `mergeme` | +| `poll-interval` | Seconds between status check polls | `30` | +| `timeout` | Max seconds to wait for status checks | `1800` | + +## Outputs + +| Output | Description | +| -------- | ------------------------------------------------------------------ | +| `result` | `merged` or `failed` — reflects the last PR processed in the queue | + +## Custom label + +```yaml +- uses: nimblehq/github-actions-workflows/merge_queue@v1.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + label: "ready-to-merge" +``` + +Update the `if` condition in the workflow to match: + +```yaml +if: github.event.label.name == 'ready-to-merge' +``` + +## Token permissions + +The default `GITHUB_TOKEN` works for repos without branch protection. If you have branch protection rules that restrict who can push or merge, you'll need a [GitHub App token](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app) or [fine-grained PAT](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#fine-grained-personal-access-tokens) with: + +- **Contents**: read and write (for rebase push) +- **Pull requests**: read and write (for merge, labels, comments) +- **Checks**: read (for polling CI status) + +## Job timeout + +GitHub Actions jobs have a default timeout of 6 hours. If your queue is deep and CI is slow, the job may be cancelled mid-way. Increase the job timeout in your workflow if needed: + +```yaml +jobs: + merge: + timeout-minutes: 720 # 12 hours +``` + +PRs that weren't reached before the timeout will keep their label and be picked up by the next trigger. + +## Why not GitHub's built-in merge queue? + +GitHub has a [native merge queue](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue), but it requires a GitHub Enterprise plan or public repos on Team/Free plans with specific configurations. `merge-queue` gives you the same core behavior on any plan: + +- Serialized merges tested against the latest base branch +- Automatic rebase before testing +- Clear feedback on failures + +And since it's just a workflow file and a composite action, you can read and modify every line of it. + +## License + +> Inspired by [opper-ai/pr-gatory-action](https://github.com/opper-ai/pr-gatory-action) + +MIT diff --git a/merge_queue/action.yml b/merge_queue/action.yml new file mode 100644 index 0000000..b3d0887 --- /dev/null +++ b/merge_queue/action.yml @@ -0,0 +1,238 @@ +name: "merge-queue" +description: "A merge queue for GitHub Actions. Label a PR, it rebases, waits for CI, and squash merges." +author: "nimblehq" + +branding: + icon: "git-merge" + color: "purple" + +inputs: + github-token: + description: "GitHub token with repo permissions" + required: true + default: ${{ github.token }} + label: + description: "Label that triggers the merge queue" + required: false + default: "mergeme" + poll-interval: + description: "Seconds between status check polls" + required: false + default: "30" + timeout: + description: "Max seconds to wait for status checks" + required: false + default: "1800" + +outputs: + result: + description: "Result of the last PR processed: merged or failed" + value: ${{ steps.merge.outputs.result }} + +runs: + using: "composite" + steps: + - name: Rebase, wait for CI, squash merge + id: merge + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + LABEL: ${{ inputs.label }} + POLL_INTERVAL: ${{ inputs.poll-interval }} + TIMEOUT: ${{ inputs.timeout }} + run: | + set -euo pipefail + REPO="${GITHUB_REPOSITORY}" + + # Remove label, post failure comment, write output + dequeue() { + local pr="$1" msg="$2" + gh api "repos/$REPO/issues/$pr/labels/$LABEL" -X DELETE 2>/dev/null || true + gh pr comment "$pr" --repo "$REPO" --body "$msg" 2>/dev/null || true + echo "result=failed" >> "$GITHUB_OUTPUT" + } + + # Process one PR through rebase → CI wait → squash merge. + # Always returns 0; failures dequeue the PR and return early. + process_pr() { + local PR_NUMBER="$1" + + # --- 1. Check PR state --- + echo "::group::PR #${PR_NUMBER} — checking state" + + local PR_JSON STATE MERGEABLE BRANCH TITLE BASE + PR_JSON=$(gh pr view "$PR_NUMBER" --repo "$REPO" \ + --json state,mergeable,headRefName,title,baseRefName) + STATE=$(echo "$PR_JSON" | jq -r '.state') + MERGEABLE=$(echo "$PR_JSON" | jq -r '.mergeable') + BRANCH=$(echo "$PR_JSON" | jq -r '.headRefName') + TITLE=$(echo "$PR_JSON" | jq -r '.title') + BASE=$(echo "$PR_JSON" | jq -r '.baseRefName') + + echo "PR: #${PR_NUMBER} — ${TITLE}" + echo "Branch: ${BRANCH} → ${BASE}" + echo "State: ${STATE} / Mergeable: ${MERGEABLE}" + echo "::endgroup::" + + if [[ "$STATE" != "OPEN" ]]; then + gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "$LABEL" 2>/dev/null || true + return 0 + fi + + if [[ "$MERGEABLE" == "CONFLICTING" ]]; then + dequeue "$PR_NUMBER" "**Merge queue:** removed — PR has conflicts with \`${BASE}\`. Resolve and re-label \`${LABEL}\` to retry." + return 0 + fi + + # GitHub computes mergeability asynchronously — poll briefly if UNKNOWN + if [[ "$MERGEABLE" == "UNKNOWN" ]]; then + local i + for i in 1 2 3; do + sleep 5 + MERGEABLE=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json mergeable --jq '.mergeable') + echo " Mergeability retry ${i}: ${MERGEABLE}" + [[ "$MERGEABLE" != "UNKNOWN" ]] && break + done + if [[ "$MERGEABLE" == "CONFLICTING" ]]; then + dequeue "$PR_NUMBER" "**Merge queue:** removed — PR has conflicts with \`${BASE}\`. Resolve and re-label \`${LABEL}\` to retry." + return 0 + fi + fi + + # --- 2. Rebase onto latest base branch --- + echo "::group::Rebasing ${BRANCH} onto ${BASE}" + + # Configure git identity for rebase commits + git config user.email "merge-queue[bot]@users.noreply.github.com" + git config user.name "merge-queue[bot]" + + git fetch origin "$BASE" "$BRANCH" + git checkout "origin/$BRANCH" + git checkout -B "$BRANCH" + + if ! git rebase "origin/$BASE"; then + git rebase --abort + echo "::endgroup::" + dequeue "$PR_NUMBER" "**Merge queue:** rebase onto \`${BASE}\` failed due to conflicts. Resolve and re-label \`${LABEL}\` to retry." + return 0 + fi + + # Push directly to token URL to ensure the PAT is used (not GITHUB_TOKEN + # from checkout). PAT pushes trigger workflows; GITHUB_TOKEN pushes don't. + local PUSH_URL="https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" + if ! git push "$PUSH_URL" "$BRANCH" --force 2>&1; then + echo "::endgroup::" + dequeue "$PR_NUMBER" "**Merge queue:** failed to push rebased branch. Re-label \`${LABEL}\` to retry." + return 0 + fi + + echo "Rebased ${BRANCH} onto latest ${BASE}" + echo "::endgroup::" + + # --- 3. Wait for status checks --- + echo "::group::Waiting for status checks on PR #${PR_NUMBER}" + + local ELAPSED=0 CHECK_RESULT="pending" CHECK_STATE CHECKS_SEEN="false" + # Grace period: wait at least this long before treating "no checks" as + # a pass, giving CI workflows time to register check runs on the new commit. + local GRACE=60 + while true; do + sleep "$POLL_INTERVAL" + ELAPSED=$((ELAPSED + POLL_INTERVAL)) + + # gh pr checks exits 1 when no checks exist — fall back to empty array + local CHECK_RAW + CHECK_RAW=$(gh pr checks "$PR_NUMBER" --repo "$REPO" --json name,state,workflow 2>/dev/null) || CHECK_RAW="[]" + CHECK_STATE=$(echo "$CHECK_RAW" | jq -r \ + '[.[] | select(.workflow != "Merge Queue")] | if length == 0 then "no_checks" elif all(.state == "SUCCESS") then "pass" elif any(.state == "FAILURE") then "fail" else "pending" end') + + echo " ${ELAPSED}s — checks: ${CHECK_STATE}" + + [[ "$CHECK_STATE" != "no_checks" ]] && CHECKS_SEEN="true" + + if [[ "$CHECK_STATE" == "pass" ]]; then + CHECK_RESULT="pass" + echo "All checks passed." + break + fi + + if [[ "$CHECK_STATE" == "no_checks" && ( "$CHECKS_SEEN" == "true" || "$ELAPSED" -ge "$GRACE" ) ]]; then + CHECK_RESULT="pass" + echo "No status checks configured — proceeding." + break + fi + + if [[ "$CHECK_STATE" == "fail" ]]; then + CHECK_RESULT="fail" + break + fi + + if [[ "$ELAPSED" -ge "$TIMEOUT" ]]; then + CHECK_RESULT="timeout" + break + fi + done + echo "::endgroup::" + + if [[ "$CHECK_RESULT" == "fail" ]]; then + dequeue "$PR_NUMBER" "**Merge queue:** status checks failed after rebase onto \`${BASE}\`. Fix and re-label \`${LABEL}\` to retry." + return 0 + fi + + if [[ "$CHECK_RESULT" == "timeout" ]]; then + dequeue "$PR_NUMBER" "**Merge queue:** timed out waiting for status checks (${TIMEOUT}s). Re-label \`${LABEL}\` to retry." + return 0 + fi + + # --- 4. Squash merge --- + echo "::group::Squash merging PR #${PR_NUMBER}" + + if gh pr merge "$PR_NUMBER" --repo "$REPO" --squash --delete-branch; then + echo "::endgroup::" + echo "::notice::PR #${PR_NUMBER} squash merged into ${BASE}" + echo "result=merged" >> "$GITHUB_OUTPUT" + else + echo "::endgroup::" + dequeue "$PR_NUMBER" "**Merge queue:** squash merge failed. This can happen if branch protection rules are not met. Fix and re-label \`${LABEL}\` to retry." + fi + } + + # --- Queue loop: process all labeled PRs in order --- + # Brief pause to let the label propagate through GitHub's API + # (the labeled event can fire before gh pr list sees the label) + sleep 5 + PROCESSED="" + EMPTY_RETRIES=0 + while true; do + PR_NUMBER=$(gh pr list --repo "$REPO" \ + --label "$LABEL" \ + --state open \ + --json number \ + --jq '[.[].number] | sort | first // empty') + + if [[ -z "$PR_NUMBER" ]]; then + if [[ "$EMPTY_RETRIES" -lt 3 ]]; then + EMPTY_RETRIES=$((EMPTY_RETRIES + 1)) + echo "Queue appears empty, retrying in 5s... (${EMPTY_RETRIES}/3)" + sleep 5 + continue + fi + echo "Queue empty." + break + fi + EMPTY_RETRIES=0 + + # Skip PRs already processed (label removal may not have propagated) + if [[ " $PROCESSED " == *" $PR_NUMBER "* ]]; then + echo "PR #${PR_NUMBER} already processed, waiting for label removal to propagate..." + sleep 5 + continue + fi + + process_pr "$PR_NUMBER" || { + # Unexpected failure — remove from queue to avoid infinite loop + gh pr edit "$PR_NUMBER" --repo "$REPO" --remove-label "$LABEL" || true + echo "result=failed" >> "$GITHUB_OUTPUT" + } + PROCESSED="$PROCESSED $PR_NUMBER" + done diff --git a/merge_queue/sample/workflows/merge_queue.yml b/merge_queue/sample/workflows/merge_queue.yml new file mode 100644 index 0000000..87733e9 --- /dev/null +++ b/merge_queue/sample/workflows/merge_queue.yml @@ -0,0 +1,26 @@ +# .github/workflows/merge-queue.yml +name: Merge Queue + +on: + pull_request: + types: [labeled] + +# Only one merge at a time — this IS the queue +concurrency: + group: merge-queue + cancel-in-progress: false + +jobs: + merge: + if: github.event.label.name == 'mergeme' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: nimblehq/github-actions-workflows/merge_queue@v1.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} From 1c55d967371ccbb4b743aa146e7a8a7341353c46 Mon Sep 17 00:00:00 2001 From: "Luong Vo (Lucas)" Date: Mon, 25 May 2026 15:16:10 +0700 Subject: [PATCH 2/3] Fix some small issues --- merge_queue/README.md | 4 ++-- merge_queue/action.yml | 11 +++++------ merge_queue/sample/workflows/merge_queue.yml | 3 ++- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/merge_queue/README.md b/merge_queue/README.md index 0b44082..74e4087 100644 --- a/merge_queue/README.md +++ b/merge_queue/README.md @@ -42,7 +42,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: nimblehq/github-actions-workflows/merge_queue@v1.0.0 + - uses: nimblehq/github-actions-workflows/merge_queue@0.2.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} ``` @@ -93,7 +93,7 @@ To retry, fix the issue and add the `mergeme` label again. ## Custom label ```yaml -- uses: nimblehq/github-actions-workflows/merge_queue@v1.0.0 +- uses: nimblehq/github-actions-workflows/merge_queue@0.2.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} label: "ready-to-merge" diff --git a/merge_queue/action.yml b/merge_queue/action.yml index b3d0887..7dea8a2 100644 --- a/merge_queue/action.yml +++ b/merge_queue/action.yml @@ -47,7 +47,7 @@ runs: # Remove label, post failure comment, write output dequeue() { local pr="$1" msg="$2" - gh api "repos/$REPO/issues/$pr/labels/$LABEL" -X DELETE 2>/dev/null || true + gh pr edit "$pr" --repo "$REPO" --remove-label "$LABEL" 2>/dev/null || true gh pr comment "$pr" --repo "$REPO" --body "$msg" 2>/dev/null || true echo "result=failed" >> "$GITHUB_OUTPUT" } @@ -103,12 +103,11 @@ runs: echo "::group::Rebasing ${BRANCH} onto ${BASE}" # Configure git identity for rebase commits - git config user.email "merge-queue[bot]@users.noreply.github.com" - git config user.name "merge-queue[bot]" + git config user.email "nimblehq-merge-queue[bot]@users.noreply.github.com" + git config user.name "nimblehq-merge-queue[bot]" git fetch origin "$BASE" "$BRANCH" - git checkout "origin/$BRANCH" - git checkout -B "$BRANCH" + git checkout -B "$BRANCH" "origin/$BRANCH" if ! git rebase "origin/$BASE"; then git rebase --abort @@ -120,7 +119,7 @@ runs: # Push directly to token URL to ensure the PAT is used (not GITHUB_TOKEN # from checkout). PAT pushes trigger workflows; GITHUB_TOKEN pushes don't. local PUSH_URL="https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" - if ! git push "$PUSH_URL" "$BRANCH" --force 2>&1; then + if ! git push "$PUSH_URL" "$BRANCH" --force-with-lease; then echo "::endgroup::" dequeue "$PR_NUMBER" "**Merge queue:** failed to push rebased branch. Re-label \`${LABEL}\` to retry." return 0 diff --git a/merge_queue/sample/workflows/merge_queue.yml b/merge_queue/sample/workflows/merge_queue.yml index 87733e9..7ea47dd 100644 --- a/merge_queue/sample/workflows/merge_queue.yml +++ b/merge_queue/sample/workflows/merge_queue.yml @@ -15,12 +15,13 @@ jobs: if: github.event.label.name == 'mergeme' runs-on: ubuntu-latest permissions: + checks: read contents: write pull-requests: write steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: nimblehq/github-actions-workflows/merge_queue@v1.0.0 + - uses: nimblehq/github-actions-workflows/merge_queue@0.2.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} From 9dd710aff6bd8b4deba215fde18b09c61efd1458 Mon Sep 17 00:00:00 2001 From: "Luong Vo (Lucas)" Date: Mon, 25 May 2026 16:14:59 +0700 Subject: [PATCH 3/3] Fix "checks" fetching issue --- merge_queue/action.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/merge_queue/action.yml b/merge_queue/action.yml index 7dea8a2..c415e1c 100644 --- a/merge_queue/action.yml +++ b/merge_queue/action.yml @@ -141,10 +141,12 @@ runs: # gh pr checks exits 1 when no checks exist — fall back to empty array local CHECK_RAW - CHECK_RAW=$(gh pr checks "$PR_NUMBER" --repo "$REPO" --json name,state,workflow 2>/dev/null) || CHECK_RAW="[]" + CHECK_RAW=$(gh pr checks "$PR_NUMBER" --repo "$REPO" --json name,state,workflow 2>/dev/null || echo "[]") CHECK_STATE=$(echo "$CHECK_RAW" | jq -r \ - '[.[] | select(.workflow != "Merge Queue")] | if length == 0 then "no_checks" elif all(.state == "SUCCESS") then "pass" elif any(.state == "FAILURE") then "fail" else "pending" end') + --arg workflow "$GITHUB_WORKFLOW" \ + '[.[] | select(.workflow != $workflow)] | if length == 0 then "no_checks" elif any(.state | test("^(FAILURE|ERROR|CANCELLED|ACTION_REQUIRED)$")) then "fail" elif all(.state | test("^(SUCCESS|NEUTRAL|SKIPPED)$")) then "pass" else "pending" end') + echo " CHECK_RAW: ${CHECK_RAW}" echo " ${ELAPSED}s — checks: ${CHECK_STATE}" [[ "$CHECK_STATE" != "no_checks" ]] && CHECKS_SEEN="true"