ci: add Dependabot auto merge workflow

This commit is contained in:
xixu-me committed 2026-07-03 23:03:41 +08:00
1 parent e50c856d92
commit 7e324254f0
1 file changed
+168 -109
+168 -109
View File
@@ -1,147 +1,206 @@
name: Dependabot auto-merge name: Dependabot Auto Merge
on: on:
workflow_run: pull_request_target:
workflows:
- CI
types: types:
- completed - opened
- synchronize
- reopened
- ready_for_review
schedule:
- cron: "*/30 * * * *"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: false
permissions: permissions:
checks: read
contents: write contents: write
pull-requests: write pull-requests: write
checks: read
statuses: read statuses: read
jobs: jobs:
merge: merge:
if: ${{ github.event.workflow_run.event == 'pull_request' }} name: Auto-merge Dependabot PRs
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- name: Inspect completed PR checks - name: Merge Dependabot PRs when checks pass
id: decision uses: actions/github-script@v9
env: with:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} script: |
GH_TOKEN: ${{ github.token }} const owner = context.repo.owner;
PULL_REQUESTS_JSON: ${{ toJson(github.event.workflow_run.pull_requests) }} const repo = context.repo.repo;
REPOSITORY: ${{ github.repository }}
WORKFLOW_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
run: |
if [ "${WORKFLOW_CONCLUSION}" != "success" ]; then
echo "Completed workflow conclusion is ${WORKFLOW_CONCLUSION}; skipping."
echo "merge=false" >> "${GITHUB_OUTPUT}"
exit 0
fi
pr_number="$(python3 - <<'PY' const successfulCheckConclusions = new Set(["success", "skipped", "neutral"]);
import json const successfulStatusStates = new Set(["success"]);
import os const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
pull_requests = json.loads(os.environ["PULL_REQUESTS_JSON"]) const latestBy = (items, keyOf, timeOf) => {
print(pull_requests[0]["number"] if pull_requests else "") const latest = new Map();
PY 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 [ -z "${pr_number}" ]; then const findCandidatePulls = async () => {
echo "Completed workflow is not associated with a pull request; skipping." if (context.eventName === "pull_request_target") {
echo "merge=false" >> "${GITHUB_OUTPUT}" const pr = context.payload.pull_request;
exit 0 return pr?.user?.login === "dependabot[bot]" && pr?.state === "open" ? [pr] : [];
fi }
pr_file="${RUNNER_TEMP}/pr.json" const pulls = await github.paginate(github.rest.pulls.list, {
checks_file="${RUNNER_TEMP}/checks.json" owner,
repo,
state: "open",
per_page: 100,
});
gh api "repos/${REPOSITORY}/pulls/${pr_number}" > "${pr_file}" return pulls.filter((pr) => pr.user?.login === "dependabot[bot]");
gh pr view "${pr_number}" \ };
--repo "${REPOSITORY}" \
--json statusCheckRollup \
> "${checks_file}"
python3 - "${pr_file}" "${checks_file}" <<'PY' const getMergeablePullRequest = async (pull_number) => {
import json for (let attempt = 1; attempt <= 6; attempt += 1) {
import os const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number });
import sys if (pr.mergeable !== null) {
return pr;
}
core.info("PR #" + pull_number + " mergeability is still being computed; retry " + attempt + "/6.");
await wait(5000);
}
GREEN_CHECK_CONCLUSIONS = {"SUCCESS", "NEUTRAL", "SKIPPED"} const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number });
GREEN_STATUS_STATES = {"SUCCESS"} return pr;
};
const getSignalState = async (sha) => {
const checkRuns = await github.paginate(github.rest.checks.listForRef, {
owner,
repo,
ref: sha,
per_page: 100,
});
def set_output(name: str, value: str) -> None: const statuses = await github.paginate(github.rest.repos.listCommitStatusesForRef, {
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: owner,
output.write(f"{name}={value}\n") repo,
ref: sha,
per_page: 100,
});
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);
def skip(reason: str) -> None: const pendingChecks = latestChecks.filter((run) => run.status !== "completed");
print(reason) const failedChecks = latestChecks.filter(
set_output("merge", "false") (run) => run.status === "completed" && !successfulCheckConclusions.has(String(run.conclusion || "").toLowerCase()),
raise SystemExit(0) );
const failedStatuses = latestStatuses.filter((status) => !successfulStatusStates.has(String(status.state || "").toLowerCase()));
return {
totalSignals: latestChecks.length + latestStatuses.length,
pendingChecks,
failedChecks,
failedStatuses,
};
};
with open(sys.argv[1], encoding="utf-8") as pr_json: const { data: repository } = await github.rest.repos.get({ owner, repo });
pull_request = json.load(pr_json) 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");
with open(sys.argv[2], encoding="utf-8") as checks_json: if (mergeMethods.length === 0) {
checks = json.load(checks_json)["statusCheckRollup"] core.info("This repository has no enabled pull request merge methods.");
return;
}
repository = os.environ["REPOSITORY"] const pulls = await findCandidatePulls();
default_branch = os.environ["DEFAULT_BRANCH"] if (pulls.length === 0) {
core.info("No open Dependabot PRs to evaluate.");
return;
}
if pull_request["user"]["login"] != "dependabot[bot]": for (const candidate of pulls) {
skip("Pull request author is not Dependabot; skipping.") const pull_number = candidate.number;
const pr = await getMergeablePullRequest(pull_number);
if pull_request["state"] != "open": if (pr.user?.login !== "dependabot[bot]") {
skip("Pull request is not open; skipping.") core.info("PR #" + pull_number + " is no longer a Dependabot PR.");
continue;
}
if pull_request["draft"]: if (pr.state !== "open" || pr.draft) {
skip("Pull request is a draft; skipping.") core.info("PR #" + pull_number + " is not an open, ready PR.");
continue;
}
if pull_request["base"]["repo"]["full_name"] != repository: if (pr.mergeable !== true) {
skip("Pull request targets a different repository; skipping.") core.info("PR #" + pull_number + " is not currently mergeable.");
continue;
}
if pull_request["head"]["repo"]["full_name"] != repository: const signalState = await getSignalState(pr.head.sha);
skip("Pull request comes from a fork; skipping.") if (signalState.totalSignals === 0) {
core.info("PR #" + pull_number + " has no checks or statuses yet; skipping.");
continue;
}
if pull_request["base"]["ref"] != default_branch: if (signalState.pendingChecks.length > 0) {
skip("Pull request does not target the default branch; skipping.") core.info("PR #" + pull_number + " still has pending checks: " + signalState.pendingChecks.map((run) => run.name).join(", ") + ".");
continue;
}
if not checks: if (signalState.failedChecks.length > 0 || signalState.failedStatuses.length > 0) {
skip("Pull request has no checks; skipping.") 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;
}
not_green = [] let merged = false;
for check in checks: let lastError = null;
check_type = check.get("__typename") for (const merge_method of mergeMethods) {
if check_type == "CheckRun": try {
name = check.get("name", "<unnamed check>") await github.rest.pulls.merge({ owner, repo, pull_number, merge_method });
status = check.get("status") core.info("Merged Dependabot PR #" + pull_number + " with " + merge_method + ".");
conclusion = check.get("conclusion") merged = true;
if status != "COMPLETED" or conclusion not in GREEN_CHECK_CONCLUSIONS: break;
not_green.append(f"{name}: {status}/{conclusion}") } catch (error) {
elif check_type == "StatusContext": lastError = error;
context = check.get("context", "<unnamed status>") if (error.status === 405 || error.status === 409) {
state = check.get("state") core.info("Cannot merge PR #" + pull_number + " with " + merge_method + ": " + error.message);
if state not in GREEN_STATUS_STATES: continue;
not_green.append(f"{context}: {state}") }
else: throw error;
not_green.append(f"Unsupported status item: {check_type}") }
}
if not_green: if (!merged) {
skip("Not all pull request checks are green:\n" + "\n".join(not_green)) core.info("PR #" + pull_number + " could not be merged: " + (lastError?.message || "unknown error") + ".");
continue;
}
set_output("merge", "true") if (pr.head.repo?.full_name === owner + "/" + repo) {
set_output("head_sha", pull_request["head"]["sha"]) try {
set_output("pr_url", pull_request["html_url"]) await github.rest.git.deleteRef({ owner, repo, ref: "heads/" + pr.head.ref });
print(f"Dependabot PR #{pull_request['number']} is green and eligible to merge.") core.info("Deleted branch " + pr.head.ref + ".");
PY } catch (error) {
core.info("Could not delete branch " + pr.head.ref + ": " + error.message);
- name: Squash-merge Dependabot PR }
if: ${{ steps.decision.outputs.merge == 'true' }} }
env: }
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ steps.decision.outputs.head_sha }}
PR_URL: ${{ steps.decision.outputs.pr_url }}
run: |
gh pr merge "${PR_URL}" \
--squash \
--delete-branch \
--match-head-commit "${HEAD_SHA}"