Skip to content

Commit 2dd9b6d

Browse files
committed
build: add Dependabot auto-merge workflow
1 parent 24ffb5b commit 2dd9b6d

File tree

2 files changed

+235
-1
lines changed

2 files changed

+235
-1
lines changed

.github/workflows/dependabot-auto-approve.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ on:
66

77
branches:
88
- main
9+
- dependabot-merge
910

1011
permissions:
1112
pull-requests: write
@@ -16,7 +17,8 @@ jobs:
1617
runs-on: ubuntu-latest
1718
# Check for success, an associated PR, and the correct target branch
1819
if: |
19-
github.event.pull_request.base.ref == 'main' &&
20+
(github.event.pull_request.base.ref == 'main' ||
21+
github.event.pull_request.base.ref == 'dependabot-merge') &&
2022
github.event.pull_request.user.login == 'dependabot[bot]'
2123
2224
steps:
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
name: Dependabot Auto Merge
2+
3+
on:
4+
check_suite:
5+
types: [completed]
6+
7+
permissions:
8+
contents: write
9+
pull-requests: write
10+
checks: read
11+
12+
jobs:
13+
final-merge:
14+
runs-on: ubuntu-latest
15+
if: |
16+
github.actor == 'dependabot[bot]' &&
17+
github.event.check_suite.pull_requests[0].base.ref == 'dependabot-merge'
18+
steps:
19+
- name: Checkout
20+
uses: actions/checkout@v6
21+
22+
- name: Attempt Merge
23+
id: attempt-merge
24+
uses: actions/github-script@v8
25+
with:
26+
github-token: ${{ secrets.GITHUB_TOKEN }}
27+
script: |
28+
const { owner, repo } = context.repo;
29+
const checkSuite = context.payload.check_suite;
30+
31+
// 1. Identify the PR number
32+
if (!checkSuite.pull_requests || checkSuite.pull_requests.length === 0) {
33+
core.setOutput('result', 'failed');
34+
core.setFailed("No PR associated with this check suite.");
35+
return;
36+
}
37+
const prNumber = checkSuite.pull_requests[0].number;
38+
core.info(`Processing PR #${prNumber}`);
39+
core.setOutput('pr_number', prNumber);
40+
41+
// Fetch full PR details
42+
const { data: pr } = await github.rest.pulls.get({
43+
owner,
44+
repo,
45+
pull_number: prNumber,
46+
});
47+
48+
// 2. Polling Loop for Checks
49+
// We wait until all OTHER checks are completed.
50+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
51+
const MAX_WAIT_MS = 10 * 60 * 1000; // 10 minutes timeout
52+
const RETRY_MS = 10000; // Check every 10 seconds
53+
const startTime = Date.now();
54+
55+
let allChecks = [];
56+
57+
core.info("Verifying check status...");
58+
59+
while (true) {
60+
// Fetch latest checks for the commit
61+
const { data: checks } = await github.rest.checks.listForRef({
62+
owner,
63+
repo,
64+
ref: pr.head.sha,
65+
filter: 'latest'
66+
});
67+
68+
allChecks = checks.check_runs;
69+
70+
// CRITICAL: Exclude THIS job from the check list.
71+
// 'context.job' is the job ID from YAML ('final-merge').
72+
// We filter out any check run with this name.
73+
const otherChecks = allChecks.filter(run => run.name !== context.job);
74+
75+
const pending = otherChecks.filter(run => run.status !== 'completed');
76+
77+
if (pending.length === 0) {
78+
core.info("All other checks have completed.");
79+
break;
80+
}
81+
82+
if (Date.now() - startTime > MAX_WAIT_MS) {
83+
core.setFailed(`Timeout waiting for checks: ${pending.map(c => c.name).join(', ')}`);
84+
pending.forEach(c => core.info(` - ${c.name}: ${c.status} / ${c.conclusion}`));
85+
return;
86+
}
87+
88+
core.info(`Waiting for ${pending.length} checks to complete:`);
89+
pending.forEach(c => core.info(` - ${c.name}: ${c.status} / ${c.conclusion}`));
90+
await sleep(RETRY_MS);
91+
}
92+
93+
// 3. Validate Conclusions
94+
// Now that everything is completed, check if they passed.
95+
const otherChecks = allChecks.filter(run => run.name !== context.job);
96+
97+
// Fail if any check is incomplete or concluded with failure/cancelled
98+
// Note: We accept 'neutral' and 'skipped' as passing
99+
const badChecks = otherChecks.filter(run =>
100+
run.status !== 'completed' ||
101+
!['success', 'skipped', 'neutral'].includes(run.conclusion)
102+
);
103+
104+
if (badChecks.length > 0) {
105+
core.setOutput('result', 'failed');
106+
core.setFailed(`Not all checks are green yet. Found ${badChecks.length} incomplete or failed checks:`);
107+
badChecks.forEach(c => core.info(` - ${c.name}: ${c.status} / ${c.conclusion}`));
108+
return;
109+
}
110+
111+
// 4. Check for the "auto-approved" label
112+
const hasLabel = pr.labels.some(l => l.name === "auto-approved");
113+
if (!hasLabel) {
114+
core.setOutput('result', 'failed');
115+
core.setFailed("PR does not have 'auto-approved' label. Skipping.");
116+
return;
117+
}
118+
119+
// 5. Check if PR is strictly up-to-date (Fast Forward possible)
120+
const prBaseSha = pr.base.sha;
121+
const targetBranch = pr.base.ref;
122+
123+
// Get current SHA of the target branch from remote
124+
const { data: refData } = await github.rest.git.getRef({
125+
owner,
126+
repo,
127+
ref: `heads/${targetBranch}`
128+
});
129+
const currentBaseSha = refData.object.sha;
130+
131+
if (prBaseSha !== currentBaseSha) {
132+
core.setOutput('result', 're-triggered');
133+
core.setFailed(`PR is behind ${targetBranch}. Cannot fast-forward merge.`);
134+
core.info("Asking Dependabot to recreate PR...");
135+
136+
await github.rest.issues.createComment({
137+
owner,
138+
repo,
139+
issue_number: prNumber,
140+
body: "@dependabot recreate"
141+
});
142+
return;
143+
}
144+
145+
// 6. Merge
146+
core.info("PR is clean, labelled, and strictly up-to-date. Merging...");
147+
try {
148+
await github.rest.pulls.merge({
149+
owner,
150+
repo,
151+
pull_number: prNumber,
152+
merge_method: 'rebase'
153+
});
154+
core.info("Merge request sent successfully.");
155+
core.setOutput('result', 'success');
156+
} catch (error) {
157+
core.setOutput('result', 'failed');
158+
core.setFailed(`Merge failed: ${error.message}`);
159+
// We do not fail the workflow here to avoid noise, just log it.
160+
}
161+
162+
- name: Comment automerge results
163+
uses: actions/github-script@v8
164+
if: always() && steps.attempt-merge.outputs.result != 're-triggered'
165+
env:
166+
MERGE_RESULT: ${{ steps.attempt-merge.outputs.result }}
167+
PR_NUMBER: ${{ steps.attempt-merge.outputs.pr_number }}
168+
with:
169+
script: |
170+
const { owner, repo } = context.repo;
171+
// Get PR number from step output (context.payload.pull_request is null in check_suite events)
172+
const prNumber = parseInt(process.env.PR_NUMBER);
173+
174+
if (!prNumber || isNaN(prNumber)) {
175+
core.info('No valid PR number found; skipping comment.');
176+
return;
177+
}
178+
179+
const jobSummaryUrl = `https://github.com/${owner}/${repo}/actions/runs/${context.runId}`;
180+
const prHead = context.payload.pull_request?.head;
181+
const headCommitSha = prHead?.sha ?? process.env.GITHUB_SHA ?? '';
182+
const shortSha = headCommitSha ? headCommitSha.slice(0, 7) : 'unknown';
183+
const runResult = process.env.MERGE_RESULT?.toLowerCase() ?? '';
184+
const isFailure = runResult === 'failed';
185+
186+
const marker = '<!-- dependabot-merge-comment -->';
187+
const heading = isFailure
188+
? '## ⚠️ Auto Merge Failed'
189+
: '## ✅ Auto Merge Succeeded';
190+
const narration = `Please check the [workflow run↗️](${jobSummaryUrl}) for details.`;
191+
const bodySections = [
192+
heading,
193+
narration
194+
];
195+
196+
const existingComments = await github.paginate(
197+
github.rest.issues.listComments,
198+
{
199+
owner,
200+
repo,
201+
issue_number: prNumber,
202+
per_page: 100,
203+
},
204+
);
205+
206+
const previous = existingComments.find((comment) =>
207+
comment.body?.includes(marker),
208+
);
209+
210+
if(previous) {
211+
bodySections.push(':recycle: This comment has been updated with latest results.');
212+
}
213+
214+
const body = `${marker}\n${bodySections.join('\n\n')}\n${marker}`;
215+
216+
if (previous) {
217+
core.info(`Updating existing auto merge comment (${previous.id}).`);
218+
await github.rest.issues.updateComment({
219+
owner,
220+
repo,
221+
comment_id: previous.id,
222+
body,
223+
});
224+
} else {
225+
core.info('Creating new auto merge comment.');
226+
await github.rest.issues.createComment({
227+
owner,
228+
repo,
229+
issue_number: prNumber,
230+
body,
231+
});
232+
}

0 commit comments

Comments
 (0)