Dependabot Auto Merge #371
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Dependabot Auto Merge | |
| on: | |
| workflow_run: | |
| workflows: | |
| - "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: ${{ 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 | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - 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 successfulCheckConclusions = new Set(["success", "skipped", "neutral"]); | |
| const successfulStatusStates = new Set(["success"]); | |
| const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); | |
| 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, | |
| state: "open", | |
| per_page: 100, | |
| }); | |
| return pulls.filter((pr) => pr.user?.login === "dependabot[bot]"); | |
| }; | |
| 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); | |
| } | |
| const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number }); | |
| return pr; | |
| }; | |
| const getSignalState = async (sha) => { | |
| const checkRuns = await github.paginate(github.rest.checks.listForRef, { | |
| owner, | |
| repo, | |
| ref: sha, | |
| per_page: 100, | |
| }); | |
| const statuses = await github.paginate(github.rest.repos.listCommitStatusesForRef, { | |
| owner, | |
| 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); | |
| 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 === 403 || 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); | |
| } | |
| } | |
| } |