Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 35 additions & 103 deletions .github/workflows/auto-merge-bot-prs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ on:
pull_request:
types: [opened, synchronize]
workflow_dispatch:
inputs:
pr_number:
description: "PR number to enable auto-merge on"
required: true
type: number

permissions:
contents: write
Expand All @@ -17,113 +22,40 @@ jobs:
# (confirmed via `gh api users/dependabot%5Bbot%5D` — `type: Bot`); a human account
# cannot register one, so the `endsWith(github.actor, '[bot]')` clause below
# doesn't broaden trust beyond the three named bots in practice. Re-verify this
# assumption if GitHub's account-namespace rules ever change.
# assumption if GitHub's account-namespace rules ever change. A manual
# workflow_dispatch run is always allowed through: triggering it at all already
# requires write access to this repo, so it carries its own authorization.
if: |
github.event_name == 'workflow_dispatch' ||
github.actor == 'dependabot[bot]' ||
github.actor == 'renovate[bot]' ||
github.actor == 'github-actions[bot]' ||
endsWith(github.actor, '[bot]')

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Wait for status checks
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const owner = context.repo.owner;
const repo = context.repo.repo;

console.log(`Waiting for status checks on PR #${pr.number}`);

// Poll for status checks to complete
let attempts = 0;
const maxAttempts = 60; // 10 minutes with 10s intervals
let allChecksPassed = false;

while (attempts < maxAttempts && !allChecksPassed) {
const checkRuns = await github.rest.checks.listForRef({
owner,
repo,
ref: pr.head.sha,
});

const statuses = await github.rest.repos.getCombinedStatusForRef({
owner,
repo,
ref: pr.head.sha,
});

console.log(`Attempt ${attempts + 1}/${maxAttempts}`);
console.log(`Check runs state: ${checkRuns.data.check_runs.length} total`);
console.log(`Combined status: ${statuses.data.state}`);

// Check if all checks are complete and passed
const completedChecks = checkRuns.data.check_runs.filter(
c => c.status === 'completed'
);

const statusPassed = statuses.data.state === 'success' ||
statuses.data.state === 'pending' ||
statuses.data.statuses.length === 0;

if (completedChecks.length === checkRuns.data.check_runs.length && statusPassed) {
allChecksPassed = true;
console.log('✓ All checks passed!');
} else {
console.log(`✗ Waiting... (${completedChecks.length}/${checkRuns.data.check_runs.length} checks complete)`);
await new Promise(resolve => setTimeout(resolve, 10000)); // Wait 10s
}

attempts++;
}

if (!allChecksPassed) {
throw new Error('Timeout waiting for checks to pass or checks failed');
}

- name: Merge pull request
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const owner = context.repo.owner;
const repo = context.repo.repo;

console.log(`Merging PR #${pr.number}`);

try {
const result = await github.rest.pulls.merge({
owner,
repo,
pull_number: pr.number,
merge_method: 'squash', // or 'merge' or 'rebase'
});

console.log(`✓ PR #${pr.number} merged successfully`);
console.log(`Merge commit: ${result.data.sha}`);
} catch (error) {
if (error.status === 405) {
console.log('PR cannot be merged (might already be merged or have conflicts)');
} else {
throw error;
}
}

- name: Handle merge failure
# Previously this job hand-rolled a "wait for status checks, then merge"
# loop by polling checks.listForRef on a 10-second/60-attempt timer. That
# loop counted THIS job's own still-running check run in its "total"
# count, so it could never observe 100% completion and always timed out
# after ~10 minutes (see #324/#325/#326). GitHub's native auto-merge
# (enabled here via `gh pr merge --auto`) delegates the "wait for
# required checks, then merge" job to GitHub itself, which has no such
# self-referential blind spot and also correctly honors branch
# protection's required-checks list instead of waiting on every check
# run present on the SHA (many of which aren't actually required).
- name: Enable auto-merge
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
run: |
echo "Enabling auto-merge for PR #${PR_NUMBER}"
gh pr merge --squash --auto "$PR_NUMBER" --repo "${{ github.repository }}"

- name: Report failure
if: failure()
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const owner = context.repo.owner;
const repo = context.repo.repo;

await github.rest.issues.createComment({
owner,
repo,
issue_number: pr.number,
body: '⚠️ Auto-merge workflow failed. Manual review required.',
})
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
run: |
gh pr comment "$PR_NUMBER" --repo "${{ github.repository }}" \
--body "⚠️ Auto-merge workflow failed to enable auto-merge. Manual review required."
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ adheres to [Semantic Versioning](https://semver.org/).

### Fixed

- **`auto-merge-bot-prs.yml` could never actually merge a bot PR — it always timed out after ~10
minutes.** The workflow polled `checks.listForRef` on the PR's head SHA in a loop, waiting for every
check run to report `completed` before merging. That check-run list includes the workflow's *own*
currently-running check run, which by definition isn't `completed` while the loop is still polling —
a self-referential deadlock that guaranteed "N-1 of N complete" forever, until the loop's own 60-attempt
timeout threw (see PRs #324, #325, #326, all of which timed out this way). Replaced the whole hand-rolled
wait-then-merge loop with GitHub's native `gh pr merge --squash --auto`, which delegates "wait for the
branch's *required* status checks, then merge" to GitHub itself — no self-polling, and it correctly
waits only on branch protection's required-checks list rather than every check run present on the SHA.
Also made the existing (previously inert) `workflow_dispatch` trigger actually usable by accepting a
`pr_number` input.
- **`run_select` / `run_select_tuned` fully materialized a query's entire result set into Python
`dict`/`RowResult` objects before truncating to `max_rows`,** rather than bounding the fetch itself —
a query without its own `LIMIT` against a large table could build millions of row objects into memory
Expand Down
Loading