diff --git a/merge_queue/README.md b/merge_queue/README.md new file mode 100644 index 0000000..74e4087 --- /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@0.2.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@0.2.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..c415e1c --- /dev/null +++ b/merge_queue/action.yml @@ -0,0 +1,239 @@ +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 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" + } + + # 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 "nimblehq-merge-queue[bot]@users.noreply.github.com" + git config user.name "nimblehq-merge-queue[bot]" + + git fetch origin "$BASE" "$BRANCH" + git checkout -B "$BRANCH" "origin/$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-with-lease; 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 || echo "[]") + CHECK_STATE=$(echo "$CHECK_RAW" | jq -r \ + --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" + + 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..7ea47dd --- /dev/null +++ b/merge_queue/sample/workflows/merge_queue.yml @@ -0,0 +1,27 @@ +# .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: + checks: read + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: nimblehq/github-actions-workflows/merge_queue@0.2.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }}