Skip to content
Merged
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
270 changes: 173 additions & 97 deletions .github/workflows/dependabot-auto-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,143 +3,219 @@ name: Dependabot Auto Merge
on:
workflow_run:
workflows:
- CI
- "Build and Push Image"
- "CI"
- "CodeQL"
- "Commit Lint"
- "Copilot"
- "Dependabot Updates"
- "Deploy to Cloudflare Pages"
- "Deploy to Cloudflare Workers"
- "Deploy to EdgeOne Pages"
- "Deploy to Vercel"
- "Sync to Pages and Functions"
types:
- completed
workflow_dispatch:

concurrency:
group: dependabot-auto-merge-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }}
cancel-in-progress: false

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

jobs:
merge:
name: Auto-merge Dependabot PRs
if: ${{ github.event.workflow_run.event == 'pull_request' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Find matching Dependabot PR
id: pr
uses: actions/github-script@v9
with:
script: |
const branch = context.payload.workflow_run.head_branch;
const { owner, repo } = context.repo;

const { data: pulls } = await github.rest.pulls.list({
owner,
repo,
state: "open",
head: `${owner}:${branch}`,
per_page: 10,
});

const pr = pulls.find((item) => item.user?.login === "dependabot[bot]");

if (!pr) {
core.info(`No open Dependabot PR found for branch ${branch}.`);
core.setOutput("should_merge", "false");
return;
}

core.setOutput("should_merge", "true");
core.setOutput("number", String(pr.number));

- name: Merge PR when checks pass
if: steps.pr.outputs.should_merge == 'true'
- name: Merge Dependabot PRs when checks pass
uses: actions/github-script@v9
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const pull_number = Number("${{ steps.pr.outputs.number }}");

const isSuccessful = (status) =>
status === "SUCCESS" ||
status === "SKIPPED" ||
status === "NEUTRAL";

const successfulCheckConclusions = new Set(["success", "skipped", "neutral"]);
const successfulStatusStates = new Set(["success"]);
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const getPullRequest = async () => {
const { data } = await github.rest.pulls.get({

const latestBy = (items, keyOf, timeOf) => {
const latest = new Map();
for (const item of items) {
const key = keyOf(item);
const itemTime = new Date(timeOf(item) || 0).getTime();
const existing = latest.get(key);
const existingTime = existing ? new Date(timeOf(existing) || 0).getTime() : -1;
if (!existing || itemTime >= existingTime) {
latest.set(key, item);
}
}
return [...latest.values()];
};

const findCandidatePulls = async () => {
const branch = context.payload.workflow_run?.head_branch;
if (context.eventName === "workflow_run" && branch) {
const { data: pulls } = await github.rest.pulls.list({
owner,
repo,
state: "open",
head: owner + ":" + branch,
per_page: 10,
});

return pulls.filter((pr) => pr.user?.login === "dependabot[bot]");
}

const pulls = await github.paginate(github.rest.pulls.list, {
owner,
repo,
pull_number,
state: "open",
per_page: 100,
});
return data;

return pulls.filter((pr) => pr.user?.login === "dependabot[bot]");
};

let pr = await getPullRequest();
const getMergeablePullRequest = async (pull_number) => {
for (let attempt = 1; attempt <= 6; attempt += 1) {
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number });
if (pr.mergeable !== null) {
return pr;
}
core.info("PR #" + pull_number + " mergeability is still being computed; retry " + attempt + "/6.");
await wait(5000);
}

if (pr.user?.login !== "dependabot[bot]") {
core.info(`PR #${pull_number} is no longer a Dependabot PR.`);
return;
}
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number });
return pr;
};

if (pr.draft) {
core.info(`PR #${pull_number} is still a draft.`);
return;
}
const getSignalState = async (sha) => {
const checkRuns = await github.paginate(github.rest.checks.listForRef, {
owner,
repo,
ref: sha,
per_page: 100,
});

for (let attempt = 1; attempt <= 6 && pr.mergeable === null; attempt += 1) {
core.info(`PR #${pull_number} mergeability is still being computed; retry ${attempt}/6.`);
await wait(5000);
pr = await getPullRequest();
}
const statuses = await github.paginate(github.rest.repos.listCommitStatusesForRef, {
owner,
repo,
ref: sha,
per_page: 100,
});

if (pr.mergeable !== true) {
core.info(`PR #${pull_number} is not currently mergeable; skipping auto-merge.`);
const latestChecks = latestBy(
checkRuns.filter((run) => run.name !== "Dependabot Auto Merge"),
(run) => (run.app?.slug || "unknown") + ":" + run.name,
(run) => run.completed_at || run.started_at || run.created_at,
);
const latestStatuses = latestBy(statuses, (status) => status.context, (status) => status.updated_at || status.created_at);

const pendingChecks = latestChecks.filter((run) => run.status !== "completed");
const failedChecks = latestChecks.filter(
(run) => run.status === "completed" && !successfulCheckConclusions.has(String(run.conclusion || "").toLowerCase()),
);
const failedStatuses = latestStatuses.filter((status) => !successfulStatusStates.has(String(status.state || "").toLowerCase()));

return {
totalSignals: latestChecks.length + latestStatuses.length,
pendingChecks,
failedChecks,
failedStatuses,
};
};

const { data: repository } = await github.rest.repos.get({ owner, repo });
const mergeMethods = [];
if (repository.allow_merge_commit) mergeMethods.push("merge");
if (repository.allow_squash_merge) mergeMethods.push("squash");
if (repository.allow_rebase_merge) mergeMethods.push("rebase");

if (mergeMethods.length === 0) {
core.info("This repository has no enabled pull request merge methods.");
return;
}

const { data: checks } = await github.rest.checks.listForRef({
owner,
repo,
ref: pr.head.sha,
});

const requiredChecks = ["Lint", "Test and Coverage", "Type Check"];
const checkMap = new Map(checks.check_runs.map((run) => [run.name, run]));
const missing = requiredChecks.filter((name) => !checkMap.has(name));
if (missing.length > 0) {
core.info(`PR #${pull_number} is missing checks: ${missing.join(", ")}.`);
const pulls = await findCandidatePulls();
if (pulls.length === 0) {
core.info("No open Dependabot PRs to evaluate.");
return;
}

const failed = requiredChecks.filter((name) => {
const run = checkMap.get(name);
return run.status !== "completed" || !isSuccessful(String(run.conclusion).toUpperCase());
});
for (const candidate of pulls) {
const pull_number = candidate.number;
const pr = await getMergeablePullRequest(pull_number);

if (failed.length > 0) {
core.info(`PR #${pull_number} still has pending or failing checks: ${failed.join(", ")}.`);
return;
}
if (pr.user?.login !== "dependabot[bot]") {
core.info("PR #" + pull_number + " is no longer a Dependabot PR.");
continue;
}

try {
await github.rest.pulls.merge({
owner,
repo,
pull_number,
merge_method: "merge",
});
} catch (error) {
if (error.status === 405 || error.status === 409) {
core.info(`PR #${pull_number} could not be merged right now: ${error.message}`);
return;
if (pr.state !== "open" || pr.draft) {
core.info("PR #" + pull_number + " is not an open, ready PR.");
continue;
}

throw error;
}
if (pr.mergeable !== true) {
core.info("PR #" + pull_number + " is not currently mergeable.");
continue;
}

const signalState = await getSignalState(pr.head.sha);
if (signalState.totalSignals === 0) {
core.info("PR #" + pull_number + " has no checks or statuses yet; skipping.");
continue;
}

if (signalState.pendingChecks.length > 0) {
core.info("PR #" + pull_number + " still has pending checks: " + signalState.pendingChecks.map((run) => run.name).join(", ") + ".");
continue;
}

if (signalState.failedChecks.length > 0 || signalState.failedStatuses.length > 0) {
const failedChecks = signalState.failedChecks.map((run) => run.name + "=" + run.conclusion).join(", ");
const failedStatuses = signalState.failedStatuses.map((status) => status.context + "=" + status.state).join(", ");
core.info("PR #" + pull_number + " is not green. Checks: " + (failedChecks || "none") + ". Statuses: " + (failedStatuses || "none") + ".");
continue;
}

let merged = false;
let lastError = null;
for (const merge_method of mergeMethods) {
try {
await github.rest.pulls.merge({ owner, repo, pull_number, merge_method });
core.info("Merged Dependabot PR #" + pull_number + " with " + merge_method + ".");
merged = true;
break;
} catch (error) {
lastError = error;
if (error.status === 403 || error.status === 405 || error.status === 409) {
core.info("Cannot merge PR #" + pull_number + " with " + merge_method + ": " + error.message);
continue;
}
throw error;
}
}

await github.rest.git.deleteRef({
owner,
repo,
ref: `heads/${pr.head.ref}`,
});
if (!merged) {
core.info("PR #" + pull_number + " could not be merged: " + (lastError?.message || "unknown error") + ".");
continue;
}

core.info(`Merged Dependabot PR #${pull_number} and deleted ${pr.head.ref}.`);
if (pr.head.repo?.full_name === owner + "/" + repo) {
try {
await github.rest.git.deleteRef({ owner, repo, ref: "heads/" + pr.head.ref });
core.info("Deleted branch " + pr.head.ref + ".");
} catch (error) {
core.info("Could not delete branch " + pr.head.ref + ": " + error.message);
}
}
}
Loading