Skip to content

Commit 06bc4c7

Browse files
committed
build: add Dependabot auto-merge workflow
1 parent 6295071 commit 06bc4c7

File tree

2 files changed

+337
-49
lines changed

2 files changed

+337
-49
lines changed

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

Lines changed: 0 additions & 49 deletions
This file was deleted.
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
name: Dependabot Auto Merge
2+
3+
on:
4+
pull_request:
5+
types: [opened, synchronize, reopened]
6+
7+
branches:
8+
- main
9+
10+
permissions:
11+
contents: write
12+
pull-requests: write
13+
checks: read
14+
15+
concurrency:
16+
group: dependabot-auto-merge-${{ github.event.pull_request.number }}
17+
cancel-in-progress: true
18+
19+
jobs:
20+
auto-merge:
21+
runs-on: ubuntu-latest
22+
if: |
23+
github.event.pull_request.base.ref == 'main' &&
24+
github.event.pull_request.user.login == 'dependabot[bot]'
25+
steps:
26+
- name: Generate App Token
27+
id: auto-merge-token
28+
uses: actions/create-github-app-token@v2
29+
with:
30+
app-id: ${{ secrets.UKHSA_STANDARDS_AUTO_MERGE_APP_CLIENT_ID }}
31+
private-key: ${{ secrets.UKHSA_STANDARDS_AUTO_MERGE_APP_PRIVATE_KEY }}
32+
repositories: |
33+
${{ github.event.repository.name }}
34+
permission-pull-requests: write
35+
permission-contents: write
36+
permission-checks: read
37+
38+
- name: Checkout
39+
uses: actions/checkout@v6
40+
with:
41+
token: ${{ steps.auto-merge-token.outputs.token }}
42+
43+
- name: Fetch Dependabot Metadata
44+
id: dependabot-metadata
45+
uses: dependabot/fetch-metadata@v2
46+
with:
47+
github-token: "${{ secrets.GITHUB_TOKEN }}"
48+
49+
- name: Attempt Merge
50+
id: attempt-merge
51+
if: |
52+
steps.dependabot-metadata.outputs.update-type == 'version-update:semver-minor' ||
53+
steps.dependabot-metadata.outputs.update-type == 'version-update:semver-patch'
54+
uses: actions/github-script@v8
55+
env:
56+
GITHUB_APP_USERNAME: '${{ steps.auto-merge-token.outputs.app-slug }}[bot]'
57+
GITHUB_APP_EMAIL: '${{ steps.auto-merge-token.outputs.user-id }}+${{ steps.auto-merge-token.outputs.app-slug }}[bot]@users.noreply.github.com'
58+
with:
59+
github-token: ${{ steps.auto-merge-token.outputs.token }}
60+
script: |
61+
const { owner, repo } = context.repo;
62+
const pr = context.payload.pull_request;
63+
const prNumber = pr.number;
64+
65+
core.info(`Processing PR #${prNumber}`);
66+
core.setOutput('result', 'failed') // Default to failed unless we succeed later
67+
68+
// 2. Polling Loop for Checks
69+
// We wait until all OTHER checks are completed.
70+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
71+
const MAX_WAIT_MS = 10 * 60 * 1000; // 10 minutes timeout
72+
const RETRY_MS = 10000; // Check every 10 seconds
73+
const startTime = Date.now();
74+
75+
let allChecks = [];
76+
77+
core.info("Verifying check status...");
78+
79+
while (true) {
80+
// Fetch latest checks for the commit
81+
const { data: checks } = await github.rest.checks.listForRef({
82+
owner,
83+
repo,
84+
ref: pr.head.sha,
85+
filter: 'latest'
86+
});
87+
88+
allChecks = checks.check_runs;
89+
90+
// CRITICAL: Exclude THIS job from the check list.
91+
// 'context.job' is the job ID from YAML ('auto-merge').
92+
// We filter out any check run with this name.
93+
const otherChecks = allChecks.filter(run => run.name !== context.job);
94+
95+
const pending = otherChecks.filter(run => run.status !== 'completed');
96+
97+
if (pending.length === 0) {
98+
core.info("All other checks have completed.");
99+
break;
100+
}
101+
102+
if (Date.now() - startTime > MAX_WAIT_MS) {
103+
core.setOutput('result', 'failed')
104+
core.setFailed(`Timeout waiting for checks: ${pending.map(c => c.name).join(', ')}`);
105+
pending.forEach(c => core.info(` - ${c.name}: ${c.status} / ${c.conclusion}`));
106+
return;
107+
}
108+
109+
core.info(`Waiting for ${pending.length} checks to complete:`);
110+
pending.forEach(c => core.info(` - ${c.name}: ${c.status}`));
111+
await sleep(RETRY_MS);
112+
}
113+
114+
// Fetch latest checks for the commit
115+
const { data: checks } = await github.rest.checks.listForRef({
116+
owner,
117+
repo,
118+
ref: pr.head.sha,
119+
filter: 'latest'
120+
});
121+
122+
allChecks = checks.check_runs;
123+
124+
// 3. Validate Conclusions
125+
// Now that everything is completed, check if they passed.
126+
const otherChecks = allChecks.filter(run => run.name !== context.job);
127+
128+
// Fail if any check is incomplete or concluded with failure/cancelled
129+
// Note: We accept 'neutral' and 'skipped' as passing
130+
const badChecks = otherChecks.filter(run =>
131+
run.status !== 'completed' ||
132+
!['success', 'skipped', 'neutral'].includes(run.conclusion)
133+
);
134+
135+
if (badChecks.length > 0) {
136+
core.setOutput('result', 'failed');
137+
core.setFailed(`Not all checks are green yet. Found ${badChecks.length} incomplete or failed checks:`);
138+
badChecks.forEach(c => core.info(` - ${c.name}: ${c.status} / ${c.conclusion}`));
139+
return;
140+
}
141+
142+
// 4. Auto Approve PR if not already approved
143+
const { data: reviews } = await github.rest.pulls.listReviews({
144+
owner,
145+
repo,
146+
pull_number: prNumber,
147+
});
148+
149+
const botUserName = process.env.GITHUB_APP_USERNAME || 'github-actions[bot]';
150+
const shortSha = pr.head.sha.slice(0, 7);
151+
152+
const alreadyApproved = reviews.some((review) =>
153+
(review.user.login === botUserName) &&
154+
review.commit_id === pr.head.sha &&
155+
review.state === 'APPROVED'
156+
);
157+
158+
if (!alreadyApproved) {
159+
await github.rest.issues.addLabels({
160+
owner,
161+
repo,
162+
issue_number: prNumber,
163+
labels: ['auto-approved']
164+
});
165+
166+
const heading = '## ✅ Dependabot PR Auto Approved';
167+
const narration = `Approved automatically because update is **non-major** and all checks have passed.`;
168+
const bodySections = [
169+
heading,
170+
narration,
171+
`Results from commit ${shortSha}`
172+
];
173+
174+
const body = `${bodySections.join('\n\n')}`;
175+
176+
await github.rest.pulls.createReview({
177+
owner,
178+
repo,
179+
pull_number: prNumber,
180+
event: 'APPROVE',
181+
body: body
182+
});
183+
}
184+
185+
// 5. Check if PR is strictly up-to-date (Fast Forward possible)
186+
const prBaseSha = pr.base.sha;
187+
const targetBranch = pr.base.ref;
188+
189+
// Get current SHA of the target branch from remote
190+
const { data: refData } = await github.rest.git.getRef({
191+
owner,
192+
repo,
193+
ref: `heads/${targetBranch}`
194+
});
195+
const currentBaseSha = refData.object.sha;
196+
197+
if (prBaseSha !== currentBaseSha) {
198+
core.setOutput('result', 're-triggered');
199+
core.setFailed(`PR is behind ${targetBranch}. Cannot fast-forward merge.`);
200+
core.info("Asking Dependabot to recreate PR...");
201+
202+
await github.rest.issues.createComment({
203+
owner,
204+
repo,
205+
issue_number: prNumber,
206+
body: "@dependabot recreate"
207+
});
208+
return;
209+
}
210+
211+
// 6. Merge
212+
core.info("PR is clean, labelled, and strictly up-to-date. Merging via Git CLI...");
213+
214+
let gitOutput = '';
215+
let gitError = '';
216+
const execOptions = {
217+
ignoreReturnCode: false,
218+
listeners: {
219+
stdout: (data) => { gitOutput += data.toString(); },
220+
stderr: (data) => { gitError += data.toString(); }
221+
}
222+
};
223+
224+
try {
225+
// Configure git user
226+
// botUserName is defined above
227+
const botUserEmail = process.env.GITHUB_APP_EMAIL || '41898282+github-actions[bot]@users.noreply.github.com';
228+
229+
await exec.exec('git', ['config', 'user.name', botUserName], execOptions);
230+
await exec.exec('git', ['config', 'user.email', botUserEmail], execOptions);
231+
// Fetch and checkout target branch
232+
await exec.exec('git', ['fetch', 'origin', targetBranch], execOptions);
233+
await exec.exec('git', ['checkout', targetBranch], execOptions);
234+
235+
// Fetch PR commit
236+
await exec.exec('git', ['fetch', 'origin', pr.head.sha], execOptions);
237+
238+
// Fast-forward merge
239+
await exec.exec('git', ['merge', '--ff-only', pr.head.sha], execOptions);
240+
241+
// Push
242+
await exec.exec('git', ['push', 'origin', targetBranch], execOptions);
243+
244+
core.info("Fast-forward merge and push successful.");
245+
core.setOutput('result', 'success');
246+
} catch (error) {
247+
core.setOutput('result', 'failed');
248+
core.info(`Git CLI failed: ${error.message}`);
249+
250+
// Check if failure is due to FF merge or Push rejection (concurrent modification)
251+
// Combine stdout and stderr for a comprehensive check
252+
const combinedOutput = (gitError + gitOutput).toLowerCase();
253+
const isFastForwardError = combinedOutput.includes('not possible to fast-forward');
254+
const isPushError = combinedOutput.includes('updates were rejected') || combinedOutput.includes('failed to push some refs');
255+
256+
if (isFastForwardError || isPushError) {
257+
core.info("Merge/Push failed due to concurrency issues. Requesting Dependabot to recreate...");
258+
await github.rest.issues.createComment({
259+
owner,
260+
repo,
261+
issue_number: prNumber,
262+
body: "@dependabot recreate"
263+
});
264+
}
265+
266+
core.setFailed(
267+
`Git CLI merge workflow failed. ` +
268+
`Error: ${error.message}. ` +
269+
`Git stdout: ${gitOutput || '[empty]'}. ` +
270+
`Git stderr: ${gitError || '[empty]'}.`
271+
);
272+
}
273+
274+
- name: Comment automerge results
275+
uses: actions/github-script@v8
276+
if: always() && steps.attempt-merge.outputs.result != 're-triggered'
277+
env:
278+
MERGE_RESULT: ${{ steps.attempt-merge.outputs.result }}
279+
with:
280+
github-token: ${{ steps.auto-merge-token.outputs.token }}
281+
script: |
282+
const { owner, repo } = context.repo;
283+
const pr = context.payload.pull_request;
284+
const prNumber = pr.number;
285+
286+
const jobSummaryUrl = `https://github.com/${owner}/${repo}/actions/runs/${context.runId}`;
287+
const shortSha = pr.head.sha.slice(0, 7);
288+
const runResult = process.env.MERGE_RESULT?.toLowerCase() ?? '';
289+
const isFailure = runResult === 'failed';
290+
291+
const marker = '<!-- dependabot-merge-comment -->';
292+
const heading = isFailure
293+
? '## ⚠️ Dependabot Auto Merge Failed'
294+
: '## 🤖 Dependabot Auto Merge Succeeded';
295+
const narration = `Results from commit ${shortSha}, view the full [workflow run↗️](${jobSummaryUrl}) for details.`;
296+
const bodySections = [
297+
heading,
298+
narration
299+
];
300+
301+
const existingComments = await github.paginate(
302+
github.rest.issues.listComments,
303+
{
304+
owner,
305+
repo,
306+
issue_number: prNumber,
307+
per_page: 100,
308+
},
309+
);
310+
311+
const previous = existingComments.find((comment) =>
312+
comment.body?.includes(marker),
313+
);
314+
315+
if (previous) {
316+
bodySections.push(':recycle: This comment has been updated with latest results.');
317+
}
318+
319+
const body = `${marker}\n${bodySections.join('\n\n')}\n${marker}`;
320+
321+
if (previous) {
322+
core.info(`Updating existing auto merge comment (${previous.id}).`);
323+
await github.rest.issues.updateComment({
324+
owner,
325+
repo,
326+
comment_id: previous.id,
327+
body,
328+
});
329+
} else {
330+
core.info('Creating new auto merge comment.');
331+
await github.rest.issues.createComment({
332+
owner,
333+
repo,
334+
issue_number: prNumber,
335+
body,
336+
});
337+
}

0 commit comments

Comments
 (0)