Skip to content

Commit 5e9c514

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

File tree

2 files changed

+237
-1
lines changed

2 files changed

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

0 commit comments

Comments
 (0)