11name : Dependabot auto-merge
22
3- on : pull_request
3+ # Two entry points, one eligibility decision.
4+ #
5+ # pull_request decides eligibility once, records the verdict as a label, arms
6+ # schedule re-arms anything GitHub silently disarmed since
7+ #
8+ # The scheduled half exists because of a measured failure. On appeler/pranaam
9+ # PR #29 the workflow armed auto-merge at 17:27:11 and GitHub disabled it at
10+ # 17:42:38, four seconds after a PR-triggered docs workflow finished with a
11+ # skipped job. Nothing re-arms, `on: pull_request` never fires again, and the
12+ # PR sits green and unmerged while every workflow run reports success. Nine
13+ # PRs had accumulated that way. A sweep converges no matter what disarmed the
14+ # PR, which a `workflow_run` trigger racing the disarm event would not.
415
5- permissions :
6- contents : write
7- pull-requests : write
16+ on :
17+ pull_request :
18+ schedule :
19+ # Every three hours, offset off the hour so it does not queue behind the
20+ # crowd of on-the-hour jobs.
21+ - cron : " 37 */3 * * *"
22+ workflow_dispatch :
23+
24+ # Granted per job rather than here: a workflow-level write grant applies to
25+ # every job, which zizmor's excessive-permissions audit rejects once there is
26+ # more than one.
27+ permissions : {}
28+
29+ env :
30+ # Eligibility is derived from Dependabot metadata, which is only available in
31+ # a pull_request context. Rather than re-derive it from a branch name in the
32+ # scheduled run -- where major-versus-minor is not recoverable -- the verdict
33+ # is written once, here, and read back later. One decision, one place.
34+ ELIGIBLE_LABEL : automerge-eligible
35+ # How long an eligible PR may stay unmerged before the sweep says so out
36+ # loud. Silence is the failure mode this workflow exists to fix, so a sweep
37+ # that quietly does nothing must still leave a mark.
38+ STALE_AFTER_HOURS : 12
839
940jobs :
10- auto-merge :
41+ classify :
42+ name : Classify and arm
1143 runs-on : ubuntu-latest
1244 timeout-minutes : 10
13- if : github.event.pull_request.user.login == 'dependabot[bot]'
45+ permissions :
46+ contents : write # arm auto-merge
47+ pull-requests : write # label, comment
48+ if : >-
49+ github.event_name == 'pull_request' &&
50+ github.event.pull_request.user.login == 'dependabot[bot]'
1451 steps :
1552 - name : Fetch Dependabot metadata
1653 id : metadata
4683 echo "ecosystem=$ECOSYSTEM group=$GROUP update-type=$UPDATE_TYPE eligible=$eligible"
4784 echo "eligible=$eligible" >> "$GITHUB_OUTPUT"
4885
86+ # The label is what makes the scheduled sweep possible without it having
87+ # to guess. Created with --force so the first run in a repo works.
88+ - name : Record the verdict on the PR
89+ if : steps.gate.outputs.eligible == 'true'
90+ env :
91+ PR_URL : ${{ github.event.pull_request.html_url }}
92+ GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
93+ run : |
94+ gh label create "$ELIGIBLE_LABEL" --force --color 0e8a16 \
95+ --description "Auto-merge policy says yes; the sweep may arm or land this"
96+ gh pr edit "$PR_URL" --add-label "$ELIGIBLE_LABEL"
97+
4998 # No `gh pr review --approve`: it fails outright where the repo has
5099 # "Allow GitHub Actions to create and approve pull requests" off, which
51100 # aborted the step before the merge ever ran. Our rulesets require status
@@ -65,10 +114,11 @@ jobs:
65114 # GitHub refuses to arm auto-merge on a PR it currently considers
66115 # mergeable, and this job finishes in seconds -- often before the
67116 # required check runs exist. Do not merge directly here: "clean" at
68- # t+4s does not mean the checks passed.
117+ # t+4s does not mean the checks passed. The scheduled sweep picks it
118+ # up once the checks are terminal, which is the safe moment.
69119 case "$err" in
70120 *"clean status"*|*"not mergeable"*|*"Auto merge is not allowed"*)
71- echo "::warning::auto-merge not armable yet; leaving PR open "
121+ echo "::warning::auto-merge not armable yet; the sweep will retry "
72122 exit 0 ;;
73123 esac
74124 exit 1
@@ -81,3 +131,107 @@ jobs:
81131 run : |
82132 gh pr comment "$PR_URL" --body \
83133 "Left open for manual review — auto-merge covers GitHub-Actions updates, our minor-and-patch groups, and patch/minor Python bumps."
134+
135+ sweep :
136+ name : Re-arm stranded PRs
137+ runs-on : ubuntu-latest
138+ timeout-minutes : 10
139+ permissions :
140+ contents : write # arm auto-merge, or squash-merge outright
141+ pull-requests : write # read labels, merge
142+ if : github.event_name != 'pull_request'
143+ steps :
144+ - name : Collect open Dependabot PRs
145+ env :
146+ GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
147+ GH_REPO : ${{ github.repository }}
148+ run : |
149+ gh pr list --author "app/dependabot" --state open --limit 100 \
150+ --json number,url,title,labels,autoMergeRequest,mergeStateStatus,statusCheckRollup,createdAt \
151+ > prs.json
152+ python3 -c "import json;print('collected',len(json.load(open('prs.json'))),'open Dependabot PRs')"
153+
154+ # Decide per PR, print one line for every one of them, and act. Check
155+ # state is read from statusCheckRollup rather than from mergeStateStatus
156+ # alone: CLEAN is GitHub's opinion about mergeability, and this job needs
157+ # the stronger fact that every check has reached a terminal state and
158+ # none of them failed before it will merge anything directly.
159+ - name : Decide what to do with each
160+ id : plan
161+ run : |
162+ python3 - <<'PY' > actions.txt
163+ import json, os, datetime as dt
164+
165+ TERMINAL_OK = {"SUCCESS", "SKIPPED", "NEUTRAL"}
166+ label = os.environ["ELIGIBLE_LABEL"]
167+ stale_after = dt.timedelta(hours=float(os.environ["STALE_AFTER_HOURS"]))
168+ now = dt.datetime.now(dt.UTC)
169+
170+ def check_state(pr):
171+ """Terminal-and-green, still-running, or failing."""
172+ rollup = pr.get("statusCheckRollup") or []
173+ if not rollup:
174+ return "no-checks"
175+ states = []
176+ for c in rollup:
177+ # CheckRun reports status/conclusion; StatusContext reports state.
178+ if c.get("__typename") == "StatusContext" or "state" in c:
179+ states.append(c.get("state") or "PENDING")
180+ elif (c.get("status") or "").upper() != "COMPLETED":
181+ states.append("PENDING")
182+ else:
183+ states.append((c.get("conclusion") or "PENDING").upper())
184+ if any(s == "PENDING" for s in states):
185+ return "running"
186+ return "green" if all(s in TERMINAL_OK for s in states) else "failing"
187+
188+ for pr in json.load(open("prs.json")):
189+ n = pr["number"]
190+ names = {l["name"] for l in pr.get("labels") or []}
191+ age = now - dt.datetime.fromisoformat(pr["createdAt"].replace("Z", "+00:00"))
192+ if label not in names:
193+ verdict, act = "ineligible (no policy label)", "none"
194+ elif pr.get("autoMergeRequest"):
195+ verdict, act = "already armed", "none"
196+ elif pr["mergeStateStatus"] in {"DIRTY", "BLOCKED", "DRAFT"}:
197+ verdict, act = f"not mergeable ({pr['mergeStateStatus']})", "none"
198+ else:
199+ state = check_state(pr)
200+ verdict, act = {
201+ "green": ("checks terminal and green", "merge"),
202+ "running": ("checks still running", "arm"),
203+ "failing": ("checks failing", "none"),
204+ "no-checks": ("no checks reported", "none"),
205+ }[state]
206+ # Only shout when the sweep is unable to act. A PR being merged
207+ # on this very run is not stranded, however old it is.
208+ stale = act == "none" and label in names and age > stale_after
209+ print(f"{n}\t{act}\t{verdict}\t{int(age.total_seconds()//3600)}h\t{int(stale)}")
210+ PY
211+ printf '%-6s %-6s %-32s %6s\n' "PR" "ACTION" "VERDICT" "AGE"
212+ while IFS=$'\t' read -r n act verdict age stale; do
213+ printf '%-6s %-6s %-32s %6s\n' "#$n" "$act" "$verdict" "$age"
214+ if [ "$stale" = "1" ]; then
215+ echo "::warning::PR #$n is eligible but still unmerged after ${age} — automation has not been able to land it"
216+ fi
217+ done < actions.txt
218+
219+ - name : Arm or land
220+ env :
221+ GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
222+ GH_REPO : ${{ github.repository }}
223+ run : |
224+ acted=0
225+ while IFS=$'\t' read -r n act _rest; do
226+ case "$act" in
227+ arm)
228+ echo "arming #$n"
229+ gh pr merge "$n" --auto --squash || echo "::warning::could not arm #$n"
230+ acted=$((acted + 1)) ;;
231+ merge)
232+ echo "merging #$n"
233+ gh pr merge "$n" --squash --delete-branch || echo "::warning::could not merge #$n"
234+ acted=$((acted + 1)) ;;
235+ esac
236+ done < actions.txt
237+ echo "sweep acted on $acted PR(s)"
0 commit comments