Skip to content
Open
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
127 changes: 108 additions & 19 deletions .github/workflows/auto-merge.yml
Original file line number Diff line number Diff line change
@@ -1,44 +1,133 @@
name: auto-merge

name: Auto Merge

on:
pull_request:
types: [opened, synchronize, reopened, labeled]


permissions:
pull-requests: write
contents: write
checks: read
statuses: read


jobs:
auto-merge:
# Skip fork PRs
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
Comment thread
badMade marked this conversation as resolved.
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps:
- name: Wait for CI checks to initialize
run: sleep 300


- name: Wait for checks to complete
uses: actions/github-script@v7.0.1
id: wait-checks
- name: Process Auto Merge
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const owner = context.repo.owner;
const repo = context.repo.repo;
const pull_number = pr.number;
const ref = pr.head.sha;

Comment thread
badMade marked this conversation as resolved.
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));

console.log("Waiting 5 minutes for CI checks to initialize...");
await wait(5 * 60 * 1000);

console.log("Waiting 5 minutes for CI completion...");
await wait(5 * 60 * 1000);

console.log("Waiting 1 minute before final status check...");
await wait(60 * 1000);

// Fetch current PR state to ensure it's still open
const { data: currentPr } = await github.rest.pulls.get({
owner, repo, pull_number
});

if (currentPr.state !== 'open') {
console.log("PR is not open. Exiting.");
return;
}

// Verify checks
const { data: checks } = await github.rest.checks.listForRef({
owner, repo, ref
});
Comment thread
badMade marked this conversation as resolved.

Comment thread
badMade marked this conversation as resolved.
const relevantChecks = checks.check_runs.filter(check =>
check.name !== context.workflow &&
check.name !== context.job &&
check.name !== 'auto-merge' &&
check.name !== 'Auto Merge'
);

if (relevantChecks.length === 0) {
await github.rest.issues.createComment({
owner, repo, issue_number: pull_number,
body: "Auto-merge skipped: Missing checks (no CI detected)."
});
return;
}

const isChecksPassing = relevantChecks.every(check =>
check.status === 'completed' &&
['success', 'skipped', 'neutral'].includes(check.conclusion)
);

if (!isChecksPassing) {
console.log("Not all checks are passing/skipped/neutral. Exiting.");
return;
}

// Verify reviews and comments
const { data: reviews } = await github.rest.pulls.listReviews({
owner, repo, pull_number
});

const { data: comments } = await github.rest.pulls.listReviewComments({
owner, repo, pull_number
});

const reviewers = new Set();
for (const review of reviews) {
if (review.user && review.user.login) reviewers.add(review.user.login);
}
for (const comment of comments) {
if (comment.user && comment.user.login) reviewers.add(comment.user.login);
}
const reviewerNames = Array.from(reviewers).join(', ');
const reviewerCount = reviewers.size;
const reviewerStats = reviewerCount > 0 ? `\nReviewer statistics: ${reviewerCount} reviewer(s) (${reviewerNames})` : '';

const hasReviewsDetected = reviews.length > 0 || comments.length > 0;
const hasReviewedLabel = currentPr.labels.some(label => label.name === 'reviewed');

if (!hasReviewsDetected) {
await github.rest.issues.createComment({
owner, repo, issue_number: pull_number,
body: "Auto-merge skipped: Pending reviews (still waiting for feedback)."
});
return;
}

// Wait up to 5 minutes for checks to complete
for (let i = 0; i < 10; i++) {
await new Promise(r => setTimeout(r, 30000));
if (hasReviewsDetected && !hasReviewedLabel) {
await github.rest.issues.createComment({
owner, repo, issue_number: pull_number,
body: `Auto-merge waiting for action: Reviews detected but 'reviewed' label is missing.${reviewerStats}`
});
return;
}

Comment thread
badMade marked this conversation as resolved.
console.log("Waiting 1 minute before merge to ensure GitHub state consistency...");
await wait(60 * 1000);

const { data: checkRuns } = await github.rest.checks.listForRef({
owner, repo,
try {
await github.rest.pulls.merge({
owner, repo, pull_number,
merge_method: 'squash'
Comment thread
badMade marked this conversation as resolved.
});
Comment on lines +122 to +125
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pin merge to verified head SHA

This merge call is not guarded with a head SHA, even though checks were evaluated earlier against ref from the original event payload. If a new commit is pushed during the built-in waits (e.g., after an opened/synchronize trigger), this run can still merge the PR’s newer head without validating that commit’s CI/review state. The GitHub merge API supports a sha precondition specifically to prevent this race, so omitting it can auto-merge unverified changes.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot, make changes based on the above suggestion.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot, make changes based on the above suggestion.

console.log("PR successfully squash merged.");
} catch (error) {
const runUrl = `https://github.com/${owner}/${repo}/actions/runs/${context.runId}`;
await github.rest.issues.createComment({
owner, repo, issue_number: pull_number,
body: `Auto-merge failed: ${error.message}\nSee [Workflow Run](${runUrl}) for details.${reviewerStats}`
});
}