Skip to content

Commit b0c6ff5

Browse files
soodokuclaude
andauthored
Re-arm Dependabot PRs that GitHub silently disarmed (#18)
* Re-arm Dependabot PRs that GitHub silently disarmed Auto-merge is armed on PR open, then GitHub disables it when a PR-triggered workflow completes with a skipped job. Nothing re-arms it and every run still reports success, so green PRs accumulate. Measured on appeler/pranaam, where nine had. Adds a scheduled sweep that re-arms or lands stranded PRs and logs a line for each one. Eligibility is still decided once, by fetch-metadata in the pull_request context, and recorded as a label the sweep reads back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Grant auto-merge permissions per job, not workflow-wide zizmor's excessive-permissions audit rejects a workflow-level write grant once there is more than one job: it applies to every job whether or not that job needs it. Both jobs here do need both scopes, but they now say so themselves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent fdfe9fa commit b0c6ff5

1 file changed

Lines changed: 162 additions & 8 deletions

File tree

.github/workflows/dependabot-auto-merge.yml

Lines changed: 162 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,53 @@
11
name: 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

940
jobs:
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
@@ -46,6 +83,18 @@ jobs:
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

Comments
 (0)