Skip to content

Auto-merge Bot PRs #1610

Auto-merge Bot PRs

Auto-merge Bot PRs #1610

Workflow file for this run

# Copyright (c) 2026 Mike Odnis
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# .github/workflows/auto-merge-bot-prs.yml
name: Auto-merge Bot PRs
on:
pull_request:
types: [opened, synchronize, reopened]
pull_request_review:
types: [submitted]
check_suite:
types:
- completed
permissions:
contents: write
pull-requests: write
checks: read
statuses: read
jobs:
auto-merge:
name: Auto-merge dependency PRs
runs-on: ubuntu-latest
# Only run for bot PRs
if: |
github.event.pull_request.user.login == 'dependabot[bot]' ||
github.event.pull_request.user.login == 'snyk-bot' ||
github.event.pull_request.user.login == 'renovate[bot]' ||
github.actor == 'dependabot[bot]' ||
github.actor == 'snyk-bot' ||
github.actor == 'renovate[bot]'
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Get PR details
id: pr-details
uses: actions/github-script@v8
with:
script: |
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request?.number || context.payload.pull_request?.number
});
return {
number: pr.number,
title: pr.title,
body: pr.body,
user: pr.user.login,
draft: pr.draft,
mergeable: pr.mergeable,
mergeable_state: pr.mergeable_state,
head_sha: pr.head.sha,
base_ref: pr.base.ref
};
- name: Check if PR is from allowed bot
id: check-bot
run: |
BOT_USER="${{ fromJson(steps.pr-details.outputs.result).user }}"
echo "Bot user: $BOT_USER"
case "$BOT_USER" in
"dependabot[bot]"|"snyk-bot"|"renovate[bot]")
echo "allowed=true" >> $GITHUB_OUTPUT
echo "bot_type=$BOT_USER" >> $GITHUB_OUTPUT
;;
*)
echo "allowed=false" >> $GITHUB_OUTPUT
echo "Bot $BOT_USER is not in the allowed list"
;;
esac
- name: Determine update type and risk level
id: analyze-changes
if: steps.check-bot.outputs.allowed == 'true'
uses: actions/github-script@v8
with:
script: |
const title = `${{ fromJson(steps.pr-details.outputs.result).title }}`.toLowerCase();
const body = `${{ fromJson(steps.pr-details.outputs.result).body }}` || '';
const botType = `${{ steps.check-bot.outputs.bot_type }}`;
let updateType = 'unknown';
let riskLevel = 'high';
let autoMergeAllowed = false;
let riskFactors = [];
let safetyFactors = [];
// Extract package information dynamically
const packageMatch = title.match(/(?:bump|update|upgrade)\s+([^\s]+)/);
const packageName = packageMatch ? packageMatch[1] : '';
// Dynamic risk assessment based on multiple factors
// 1. Package type analysis
const isDevDependency = body.includes('devDependencies') ||
title.includes('dev-dependencies') ||
packageName.includes('@types/') ||
packageName.match(/^(eslint|prettier|jest|webpack|babel|rollup|vite|parcel)/);
const isTypesPackage = packageName.includes('@types/');
const isToolingPackage = packageName.match(/^(eslint|prettier|jest|typescript|webpack|babel)/);
const isTestingPackage = packageName.match(/^(jest|mocha|chai|cypress|playwright|vitest)/);
const isBuildToolPackage = packageName.match(/^(webpack|rollup|vite|parcel|esbuild)/);
// 2. Security context analysis
const isSecurityUpdate = botType === 'snyk-bot' ||
title.includes('security') ||
title.includes('vulnerability') ||
body.includes('vulnerability') ||
body.includes('security fix');
// 3. Breaking change indicators
const hasBreakingChangeWarning = body.includes('breaking change') ||
body.includes('breaking changes') ||
body.includes('BREAKING CHANGE') ||
body.includes('may be a breaking change');
// 4. Version jump analysis
const versionMatch = body.match(/from\s+([\d.]+)\s+to\s+([\d.]+)/);
let majorVersionJump = 0;
let isLargeJump = false;
if (versionMatch) {
const [, fromVersion, toVersion] = versionMatch;
const fromMajor = parseInt(fromVersion.split('.')[0]);
const toMajor = parseInt(toVersion.split('.')[0]);
majorVersionJump = toMajor - fromMajor;
// Check if it's a large version jump (more than 2 major versions)
isLargeJump = majorVersionJump > 2;
}
// 5. Update frequency/stability analysis
const versionsAheadMatch = body.match(/(\d+)\s+versions?\s+ahead/);
const manyVersionsBehind = versionsAheadMatch && parseInt(versionsAheadMatch[1]) > 10;
// 6. Recency analysis
const recentlyReleasedMatch = body.match(/(day|days|week|weeks)\s+ago/);
const isRecentRelease = recentlyReleasedMatch !== null;
// Calculate risk factors
if (hasBreakingChangeWarning) riskFactors.push('explicit-breaking-changes');
if (isLargeJump) riskFactors.push(`large-version-jump-${majorVersionJump}`);
if (manyVersionsBehind) riskFactors.push('many-versions-behind');
if (!isRecentRelease && !isSecurityUpdate) riskFactors.push('old-release');
// Calculate safety factors
if (isDevDependency) safetyFactors.push('dev-dependency');
if (isTypesPackage) safetyFactors.push('types-only');
if (isToolingPackage) safetyFactors.push('development-tooling');
if (isTestingPackage) safetyFactors.push('testing-framework');
if (isBuildToolPackage) safetyFactors.push('build-tool');
if (isSecurityUpdate) safetyFactors.push('security-priority');
if (isRecentRelease) safetyFactors.push('recent-release');
// Dynamic decision making
const riskScore = riskFactors.length;
const safetyScore = safetyFactors.length;
const netSafetyScore = safetyScore - riskScore;
console.log(`Package: ${packageName}`);
console.log(`Risk factors (${riskScore}): ${riskFactors.join(', ')}`);
console.log(`Safety factors (${safetyScore}): ${safetyFactors.join(', ')}`);
console.log(`Net safety score: ${netSafetyScore}`);
// Determine update type and permissions
if (isSecurityUpdate) {
updateType = 'security';
if (hasBreakingChangeWarning && !isDevDependency && riskScore > safetyScore) {
// High-risk security update with breaking changes
riskLevel = 'high';
autoMergeAllowed = false;
console.log('Security update blocked: High risk with breaking changes');
} else if (netSafetyScore >= 0) {
// Security update with neutral or positive safety score
riskLevel = 'medium';
autoMergeAllowed = true;
console.log('Security update approved: Acceptable risk profile');
} else {
// Security update with negative safety score
riskLevel = 'high';
autoMergeAllowed = false;
console.log('Security update blocked: Negative safety score');
}
} else {
// Regular dependency updates
if (title.includes('patch') || body.includes('patch')) {
updateType = 'patch';
riskLevel = 'low';
autoMergeAllowed = true;
} else if (title.includes('minor') || body.includes('minor')) {
updateType = 'minor';
riskLevel = netSafetyScore >= 0 ? 'low' : 'medium';
autoMergeAllowed = netSafetyScore >= -1; // Allow if not too risky
} else if (title.includes('major') || body.includes('major')) {
updateType = 'major';
if (netSafetyScore >= 2) {
// High confidence in safety
riskLevel = 'low';
autoMergeAllowed = true;
console.log('Major update approved: High safety confidence');
} else if (netSafetyScore >= 0 && !hasBreakingChangeWarning) {
// Moderate confidence, no explicit breaking changes
riskLevel = 'medium';
autoMergeAllowed = true;
console.log('Major update approved: Moderate safety confidence');
} else {
// Low confidence or explicit risks
riskLevel = 'high';
autoMergeAllowed = false;
console.log('Major update blocked: Low safety confidence or high risk');
}
}
}
// Final safety override for dev dependencies
if (isDevDependency && !hasBreakingChangeWarning) {
riskLevel = 'low';
autoMergeAllowed = true;
console.log('Dev dependency override: Auto-merge enabled');
}
console.log(`Final decision: ${updateType} update, ${riskLevel} risk, auto-merge: ${autoMergeAllowed}`);
return {
updateType,
riskLevel,
autoMergeAllowed,
packageName,
riskFactors,
safetyFactors,
riskScore,
safetyScore,
netSafetyScore
};
- name: Enhanced merge decision
if: steps.analyze-changes.outputs.result && fromJson(steps.analyze-changes.outputs.result).autoMergeAllowed
uses: actions/github-script@v8
with:
script: |
const analysis = ${{ steps.analyze-changes.outputs.result }};
const prNumber = ${{ fromJson(steps.pr-details.outputs.result).number }};
// Add detailed analysis comment
const analysisComment = `🤖 **Auto-merge Analysis**\n\n` +
`**Package:** \`${analysis.packageName}\`\n` +
`**Update Type:** ${analysis.updateType}\n` +
`**Risk Level:** ${analysis.riskLevel}\n` +
`**Net Safety Score:** ${analysis.netSafetyScore} (${analysis.safetyScore} safety - ${analysis.riskScore} risk)\n\n` +
`**Safety Factors:** ${analysis.safetyFactors.length > 0 ? analysis.safetyFactors.join(', ') : 'none'}\n` +
`**Risk Factors:** ${analysis.riskFactors.length > 0 ? analysis.riskFactors.join(', ') : 'none'}\n\n` +
`✅ **Decision:** Auto-merge approved based on dynamic risk assessment.`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: analysisComment
});
console.log('Added detailed analysis comment to PR');
- name: Wait for status checks
id: wait-for-checks
if: steps.analyze-changes.outputs.result && fromJson(steps.analyze-changes.outputs.result).autoMergeAllowed
uses: actions/github-script@v8
with:
script: |
const maxWaitTime = 10 * 60 * 1000; // 10 minutes
const pollInterval = 30 * 1000; // 30 seconds
const startTime = Date.now();
const prNumber = ${{ fromJson(steps.pr-details.outputs.result).number }};
const headSha = '${{ fromJson(steps.pr-details.outputs.result).head_sha }}';
while (Date.now() - startTime < maxWaitTime) {
// Get status checks
const { data: statusChecks } = await github.rest.repos.getCombinedStatusForRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: headSha
});
// Get check runs
const { data: checkRuns } = await github.rest.checks.listForRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: headSha
});
const allStatuses = statusChecks.statuses || [];
const allCheckRuns = checkRuns.check_runs || [];
// Check if we have any status checks or check runs
const hasChecks = allStatuses.length > 0 || allCheckRuns.length > 0;
if (!hasChecks) {
console.log('No status checks found, waiting...');
await new Promise(resolve => setTimeout(resolve, pollInterval));
continue;
}
// Check status checks
const pendingStatuses = allStatuses.filter(status => status.state === 'pending');
const failedStatuses = allStatuses.filter(status => status.state === 'failure' || status.state === 'error');
// Check check runs
const pendingCheckRuns = allCheckRuns.filter(run =>
run.status === 'queued' || run.status === 'in_progress'
);
const failedCheckRuns = allCheckRuns.filter(run =>
run.conclusion === 'failure' || run.conclusion === 'cancelled' || run.conclusion === 'timed_out'
);
if (failedStatuses.length > 0 || failedCheckRuns.length > 0) {
console.log('Some checks failed:');
failedStatuses.forEach(status => console.log(`- ${status.context}: ${status.state}`));
failedCheckRuns.forEach(run => console.log(`- ${run.name}: ${run.conclusion}`));
return { success: false, reason: 'checks_failed' };
}
if (pendingStatuses.length === 0 && pendingCheckRuns.length === 0) {
console.log('All checks passed!');
return { success: true, reason: 'checks_passed' };
}
console.log(`Waiting for ${pendingStatuses.length + pendingCheckRuns.length} checks to complete...`);
await new Promise(resolve => setTimeout(resolve, pollInterval));
}
console.log('Timeout waiting for checks');
return { success: false, reason: 'timeout' };
- name: Check for merge conflicts
id: check-conflicts
if: fromJson(steps.wait-for-checks.outputs.result).success
uses: actions/github-script@v8
with:
script: |
const prNumber = ${{ fromJson(steps.pr-details.outputs.result).number }};
// Get fresh PR details to check current mergeable state
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
console.log(`PR mergeable: ${pr.mergeable}, mergeable_state: ${pr.mergeable_state}`);
// Check for conflicts
if (pr.mergeable === false || pr.mergeable_state === 'dirty') {
return {
hasConflicts: true,
mergeable: pr.mergeable,
mergeable_state: pr.mergeable_state,
prState: pr.state
};
}
return {
hasConflicts: false,
mergeable: pr.mergeable,
mergeable_state: pr.mergeable_state,
prState: pr.state
};
- name: Close conflicted PR
if: fromJson(steps.check-conflicts.outputs.result).hasConflicts
uses: actions/github-script@v8
with:
script: |
const prNumber = ${{ fromJson(steps.pr-details.outputs.result).number }};
const botType = `${{ steps.check-bot.outputs.bot_type }}`;
console.log(`Closing PR #${prNumber} due to merge conflicts`);
// Add explanatory comment before closing
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: `🔄 **Auto-closing due to merge conflicts**\n\n` +
`This PR has merge conflicts with the base branch and cannot be automatically merged.\n\n` +
`**What happens next:**\n` +
`- This PR will be closed to allow ${botType} to detect the conflicts\n` +
`- ${botType} will automatically create a new PR with the updated dependencies\n` +
`- The new PR will be based on the latest base branch and should resolve the conflicts\n\n` +
`**Mergeable state:** \`${{ fromJson(steps.check-conflicts.outputs.result).mergeable_state }}\`\n` +
`**Mergeable:** \`${{ fromJson(steps.check-conflicts.outputs.result).mergeable }}\`\n\n` +
`*This action was performed automatically by the auto-merge workflow.*`
});
// Close the PR
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
state: 'closed'
});
console.log(`Successfully closed PR #${prNumber}`);
- name: Check PR requirements
id: check-requirements
if: fromJson(steps.check-conflicts.outputs.result).hasConflicts == false
uses: actions/github-script@v8
with:
script: |
const prNumber = ${{ fromJson(steps.pr-details.outputs.result).number }};
// Get PR details again to check current state
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
// Check if PR is still open
if (pr.state !== 'open') {
return { canMerge: false, reason: 'PR is not open' };
}
if (pr.draft) {
return { canMerge: false, reason: 'PR is in draft state' };
}
// Double-check mergeable state (should be clean at this point)
if (pr.mergeable === false) {
return { canMerge: false, reason: 'PR still has merge conflicts' };
}
if (pr.mergeable_state === 'blocked') {
return { canMerge: false, reason: 'PR is blocked by branch protection rules' };
}
if (pr.mergeable_state === 'dirty') {
return { canMerge: false, reason: 'PR has merge conflicts' };
}
// Check if there are any requested reviews from humans
const { data: reviews } = await github.rest.pulls.listReviews({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
const humanReviews = reviews.filter(review =>
!review.user.login.includes('bot') &&
review.state === 'CHANGES_REQUESTED'
);
if (humanReviews.length > 0) {
return { canMerge: false, reason: 'Human reviewers requested changes' };
}
return { canMerge: true, reason: 'All requirements met' };
- name: Auto-approve PR
if: fromJson(steps.check-conflicts.outputs.result).hasConflicts == false && fromJson(steps.check-requirements.outputs.result).canMerge
uses: actions/github-script@v8
with:
script: |
const prNumber = ${{ fromJson(steps.pr-details.outputs.result).number }};
try {
await github.rest.pulls.createReview({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
event: 'APPROVE',
body: '✅ Auto-approved by workflow: This is a low-risk dependency update that passed all checks.'
});
console.log('PR approved successfully');
} catch (error) {
console.log('Could not approve PR (might already be approved):', error.message);
}
- name: Merge PR
if: fromJson(steps.check-conflicts.outputs.result).hasConflicts == false && fromJson(steps.check-requirements.outputs.result).canMerge
uses: actions/github-script@v8
with:
script: |
const prNumber = ${{ fromJson(steps.pr-details.outputs.result).number }};
const updateType = `${{ fromJson(steps.analyze-changes.outputs.result).updateType }}`;
const botType = `${{ steps.check-bot.outputs.bot_type }}`;
try {
const result = await github.rest.pulls.merge({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
commit_title: `Auto-merge: ${botType} ${updateType} update`,
commit_message: `Automatically merged ${botType} PR after successful checks.\n\nUpdate type: ${updateType}\nPR: #${prNumber}`,
merge_method: 'squash' // Use squash merge to keep history clean
});
console.log(`Successfully merged PR #${prNumber}`);
// Add a comment explaining the auto-merge
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: `🤖 **Auto-merged by workflow**\n\n` +
`This ${updateType} update from ${botType} was automatically merged because:\n` +
`- All status checks passed ✅\n` +
`- No merge conflicts detected ✅\n` +
`- Update type is considered low/medium risk ✅\n` +
`- No human reviews requested changes ✅`
});
} catch (error) {
console.error('Failed to merge PR:', error.message);
// Add a comment explaining why auto-merge failed
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: `⚠️ **Auto-merge failed**\n\n` +
`Could not automatically merge this PR: ${error.message}\n` +
`Manual intervention may be required.`
});
throw error;
}
- name: Cleanup on failure
if: failure() && steps.pr-details.outputs.result
uses: actions/github-script@v8
with:
script: |
const prDetails = ${{ steps.pr-details.outputs.result || '{}' }};
if (!prDetails.number) {
console.log('No PR number available, skipping cleanup comment');
return;
}
const prNumber = prDetails.number;
// Only add comment if PR wasn't closed due to conflicts
const conflictCheck = ${{ steps.check-conflicts.outputs.result || '{}' }};
if (conflictCheck.hasConflicts) {
console.log('PR was closed due to conflicts, skipping failure comment');
return;
}
// Add a comment if the workflow failed
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: `❌ **Auto-merge workflow failed**\n\n` +
`The auto-merge workflow encountered an error. Please check the workflow logs and merge manually if appropriate.\n` +
`Workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}`
});