ci: add Dependabot auto merge workflow

This commit is contained in:
xixu-me committed 2026-07-03 23:04:10 +08:00
1 parent 1a07f59155
commit 0b636a2f36
1 file changed
+182 -95
+182 -95
View File
@@ -1,119 +1,206 @@
name: Dependabot Auto Merge
on:
pull_request:
pull_request_target:
types:
- opened
- reopened
- synchronize
- reopened
- ready_for_review
paths-ignore:
- '**/*.md'
- 'LICENSE'
- '.editorconfig'
- '.gitattributes'
- '.gitignore'
- '.prettierignore'
- '.prettierrc'
- 'codecov.yml'
permissions:
actions: read
checks: read
contents: write
pull-requests: write
schedule:
- cron: "*/30 * * * *"
workflow_dispatch:
concurrency:
group: dependabot-automerge-${{ github.event.pull_request.number }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: false
permissions:
contents: write
pull-requests: write
checks: read
statuses: read
jobs:
enable:
if: >
github.actor == 'dependabot[bot]' &&
github.event.pull_request.user.login == 'dependabot[bot]' &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.draft == false
merge:
name: Auto-merge Dependabot PRs
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Fetch Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98
- name: Merge Dependabot PRs when checks pass
uses: actions/github-script@v9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
- name: Wait for pull request checks to pass
if: >
contains(fromJSON('["version-update:semver-patch","version-update:semver-minor"]'), steps.metadata.outputs.update-type) ||
(steps.metadata.outputs.update-type == 'version-update:semver-major' &&
(steps.metadata.outputs.package-ecosystem == 'github-actions' ||
steps.metadata.outputs.dependency-type == 'direct:development'))
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
run: |
set -euo pipefail
const successfulCheckConclusions = new Set(["success", "skipped", "neutral"]);
const successfulStatusStates = new Set(["success"]);
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
remaining_attempts=180
while [ "$remaining_attempts" -gt 0 ]; do
set +e
checks="$(
gh pr checks "$PR_URL" \
--json bucket,name,state,workflow \
--jq '[.[] | select(.workflow != "Dependabot Auto Merge")]'
)"
checks_status="$?"
set -e
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()];
};
if [ "$checks_status" -ne 0 ] && [ "$checks_status" -ne 8 ]; then
exit "$checks_status"
fi
const findCandidatePulls = async () => {
if (context.eventName === "pull_request_target") {
const pr = context.payload.pull_request;
return pr?.user?.login === "dependabot[bot]" && pr?.state === "open" ? [pr] : [];
}
check_count="$(jq 'length' <<<"$checks")"
if [ "$check_count" -eq 0 ]; then
echo "No pull request checks found yet."
sleep 10
remaining_attempts=$((remaining_attempts - 1))
continue
fi
const pulls = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: "open",
per_page: 100,
});
ci_check_count="$(jq '[.[] | select(.workflow == "CI")] | length' <<<"$checks")"
if [ "$ci_check_count" -eq 0 ]; then
echo "CI checks have not appeared yet."
sleep 10
remaining_attempts=$((remaining_attempts - 1))
continue
fi
return pulls.filter((pr) => pr.user?.login === "dependabot[bot]");
};
failures="$(jq -r '.[] | select(.bucket == "fail" or .bucket == "cancel") | "- \(.name) [\(.workflow // "external")]: \(.state)"' <<<"$checks")"
if [ -n "$failures" ]; then
echo "One or more pull request checks failed:"
echo "$failures"
exit 1
fi
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);
}
pending="$(jq -r '.[] | select(.bucket == "pending") | "- \(.name) [\(.workflow // "external")]: \(.state)"' <<<"$checks")"
if [ -z "$pending" ]; then
jq -r '.[] | "- \(.name) [\(.workflow // "external")]: \(.state)"' <<<"$checks"
exit 0
fi
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number });
return pr;
};
echo "Waiting for pull request checks:"
echo "$pending"
sleep 10
remaining_attempts=$((remaining_attempts - 1))
done
const getSignalState = async (sha) => {
const checkRuns = await github.paginate(github.rest.checks.listForRef, {
owner,
repo,
ref: sha,
per_page: 100,
});
echo "Timed out waiting for pull request checks to complete."
exit 1
const statuses = await github.paginate(github.rest.repos.listCommitStatusesForRef, {
owner,
repo,
ref: sha,
per_page: 100,
});
- name: Enable auto-merge for safe update
if: >
contains(fromJSON('["version-update:semver-patch","version-update:semver-minor"]'), steps.metadata.outputs.update-type) ||
(steps.metadata.outputs.update-type == 'version-update:semver-major' &&
(steps.metadata.outputs.package-ecosystem == 'github-actions' ||
steps.metadata.outputs.dependency-type == 'direct:development'))
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
run: gh pr merge --auto --merge "$PR_URL"
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 pulls = await findCandidatePulls();
if (pulls.length === 0) {
core.info("No open Dependabot PRs to evaluate.");
return;
}
for (const candidate of pulls) {
const pull_number = candidate.number;
const pr = await getMergeablePullRequest(pull_number);
if (pr.user?.login !== "dependabot[bot]") {
core.info("PR #" + pull_number + " is no longer a Dependabot PR.");
continue;
}
if (pr.state !== "open" || pr.draft) {
core.info("PR #" + pull_number + " is not an open, ready PR.");
continue;
}
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 === 405 || error.status === 409) {
core.info("Cannot merge PR #" + pull_number + " with " + merge_method + ": " + error.message);
continue;
}
throw error;
}
}
if (!merged) {
core.info("PR #" + pull_number + " could not be merged: " + (lastError?.message || "unknown error") + ".");
continue;
}
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);
}
}
}