From e162f461d838d595928dcfc4496488a28af1265e Mon Sep 17 00:00:00 2001 From: ***** <721466+soodoku@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:58:04 -0700 Subject: [PATCH 1/2] 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) --- .github/workflows/dependabot-auto-merge.yml | 157 +++++++++++++++++++- 1 file changed, 152 insertions(+), 5 deletions(-) diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 083c32d..c46f48e 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -1,16 +1,49 @@ name: Dependabot auto-merge -on: pull_request +# Two entry points, one eligibility decision. +# +# pull_request decides eligibility once, records the verdict as a label, arms +# schedule re-arms anything GitHub silently disarmed since +# +# The scheduled half exists because of a measured failure. On appeler/pranaam +# PR #29 the workflow armed auto-merge at 17:27:11 and GitHub disabled it at +# 17:42:38, four seconds after a PR-triggered docs workflow finished with a +# skipped job. Nothing re-arms, `on: pull_request` never fires again, and the +# PR sits green and unmerged while every workflow run reports success. Nine +# PRs had accumulated that way. A sweep converges no matter what disarmed the +# PR, which a `workflow_run` trigger racing the disarm event would not. + +on: + pull_request: + schedule: + # Every three hours, offset off the hour so it does not queue behind the + # crowd of on-the-hour jobs. + - cron: "37 */3 * * *" + workflow_dispatch: permissions: contents: write pull-requests: write +env: + # Eligibility is derived from Dependabot metadata, which is only available in + # a pull_request context. Rather than re-derive it from a branch name in the + # scheduled run -- where major-versus-minor is not recoverable -- the verdict + # is written once, here, and read back later. One decision, one place. + ELIGIBLE_LABEL: automerge-eligible + # How long an eligible PR may stay unmerged before the sweep says so out + # loud. Silence is the failure mode this workflow exists to fix, so a sweep + # that quietly does nothing must still leave a mark. + STALE_AFTER_HOURS: 12 + jobs: - auto-merge: + classify: + name: Classify and arm runs-on: ubuntu-latest timeout-minutes: 10 - if: github.event.pull_request.user.login == 'dependabot[bot]' + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.user.login == 'dependabot[bot]' steps: - name: Fetch Dependabot metadata id: metadata @@ -46,6 +79,18 @@ jobs: echo "ecosystem=$ECOSYSTEM group=$GROUP update-type=$UPDATE_TYPE eligible=$eligible" echo "eligible=$eligible" >> "$GITHUB_OUTPUT" + # The label is what makes the scheduled sweep possible without it having + # to guess. Created with --force so the first run in a repo works. + - name: Record the verdict on the PR + if: steps.gate.outputs.eligible == 'true' + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh label create "$ELIGIBLE_LABEL" --force --color 0e8a16 \ + --description "Auto-merge policy says yes; the sweep may arm or land this" + gh pr edit "$PR_URL" --add-label "$ELIGIBLE_LABEL" + # No `gh pr review --approve`: it fails outright where the repo has # "Allow GitHub Actions to create and approve pull requests" off, which # aborted the step before the merge ever ran. Our rulesets require status @@ -65,10 +110,11 @@ jobs: # GitHub refuses to arm auto-merge on a PR it currently considers # mergeable, and this job finishes in seconds -- often before the # required check runs exist. Do not merge directly here: "clean" at - # t+4s does not mean the checks passed. + # t+4s does not mean the checks passed. The scheduled sweep picks it + # up once the checks are terminal, which is the safe moment. case "$err" in *"clean status"*|*"not mergeable"*|*"Auto merge is not allowed"*) - echo "::warning::auto-merge not armable yet; leaving PR open" + echo "::warning::auto-merge not armable yet; the sweep will retry" exit 0 ;; esac exit 1 @@ -81,3 +127,104 @@ jobs: run: | gh pr comment "$PR_URL" --body \ "Left open for manual review — auto-merge covers GitHub-Actions updates, our minor-and-patch groups, and patch/minor Python bumps." + + sweep: + name: Re-arm stranded PRs + runs-on: ubuntu-latest + timeout-minutes: 10 + if: github.event_name != 'pull_request' + steps: + - name: Collect open Dependabot PRs + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + gh pr list --author "app/dependabot" --state open --limit 100 \ + --json number,url,title,labels,autoMergeRequest,mergeStateStatus,statusCheckRollup,createdAt \ + > prs.json + python3 -c "import json;print('collected',len(json.load(open('prs.json'))),'open Dependabot PRs')" + + # Decide per PR, print one line for every one of them, and act. Check + # state is read from statusCheckRollup rather than from mergeStateStatus + # alone: CLEAN is GitHub's opinion about mergeability, and this job needs + # the stronger fact that every check has reached a terminal state and + # none of them failed before it will merge anything directly. + - name: Decide what to do with each + id: plan + run: | + python3 - <<'PY' > actions.txt + import json, os, datetime as dt + + TERMINAL_OK = {"SUCCESS", "SKIPPED", "NEUTRAL"} + label = os.environ["ELIGIBLE_LABEL"] + stale_after = dt.timedelta(hours=float(os.environ["STALE_AFTER_HOURS"])) + now = dt.datetime.now(dt.UTC) + + def check_state(pr): + """Terminal-and-green, still-running, or failing.""" + rollup = pr.get("statusCheckRollup") or [] + if not rollup: + return "no-checks" + states = [] + for c in rollup: + # CheckRun reports status/conclusion; StatusContext reports state. + if c.get("__typename") == "StatusContext" or "state" in c: + states.append(c.get("state") or "PENDING") + elif (c.get("status") or "").upper() != "COMPLETED": + states.append("PENDING") + else: + states.append((c.get("conclusion") or "PENDING").upper()) + if any(s == "PENDING" for s in states): + return "running" + return "green" if all(s in TERMINAL_OK for s in states) else "failing" + + for pr in json.load(open("prs.json")): + n = pr["number"] + names = {l["name"] for l in pr.get("labels") or []} + age = now - dt.datetime.fromisoformat(pr["createdAt"].replace("Z", "+00:00")) + if label not in names: + verdict, act = "ineligible (no policy label)", "none" + elif pr.get("autoMergeRequest"): + verdict, act = "already armed", "none" + elif pr["mergeStateStatus"] in {"DIRTY", "BLOCKED", "DRAFT"}: + verdict, act = f"not mergeable ({pr['mergeStateStatus']})", "none" + else: + state = check_state(pr) + verdict, act = { + "green": ("checks terminal and green", "merge"), + "running": ("checks still running", "arm"), + "failing": ("checks failing", "none"), + "no-checks": ("no checks reported", "none"), + }[state] + # Only shout when the sweep is unable to act. A PR being merged + # on this very run is not stranded, however old it is. + stale = act == "none" and label in names and age > stale_after + print(f"{n}\t{act}\t{verdict}\t{int(age.total_seconds()//3600)}h\t{int(stale)}") + PY + printf '%-6s %-6s %-32s %6s\n' "PR" "ACTION" "VERDICT" "AGE" + while IFS=$'\t' read -r n act verdict age stale; do + printf '%-6s %-6s %-32s %6s\n' "#$n" "$act" "$verdict" "$age" + if [ "$stale" = "1" ]; then + echo "::warning::PR #$n is eligible but still unmerged after ${age} — automation has not been able to land it" + fi + done < actions.txt + + - name: Arm or land + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + acted=0 + while IFS=$'\t' read -r n act _rest; do + case "$act" in + arm) + echo "arming #$n" + gh pr merge "$n" --auto --squash || echo "::warning::could not arm #$n" + acted=$((acted + 1)) ;; + merge) + echo "merging #$n" + gh pr merge "$n" --squash --delete-branch || echo "::warning::could not merge #$n" + acted=$((acted + 1)) ;; + esac + done < actions.txt + echo "sweep acted on $acted PR(s)" From 1bc2659f3ddaee16ed97702e269887fd89f0d03e Mon Sep 17 00:00:00 2001 From: ***** <721466+soodoku@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:02:28 -0700 Subject: [PATCH 2/2] 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) --- .github/workflows/dependabot-auto-merge.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index c46f48e..637e804 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -21,9 +21,10 @@ on: - cron: "37 */3 * * *" workflow_dispatch: -permissions: - contents: write - pull-requests: write +# Granted per job rather than here: a workflow-level write grant applies to +# every job, which zizmor's excessive-permissions audit rejects once there is +# more than one. +permissions: {} env: # Eligibility is derived from Dependabot metadata, which is only available in @@ -41,6 +42,9 @@ jobs: name: Classify and arm runs-on: ubuntu-latest timeout-minutes: 10 + permissions: + contents: write # arm auto-merge + pull-requests: write # label, comment if: >- github.event_name == 'pull_request' && github.event.pull_request.user.login == 'dependabot[bot]' @@ -132,6 +136,9 @@ jobs: name: Re-arm stranded PRs runs-on: ubuntu-latest timeout-minutes: 10 + permissions: + contents: write # arm auto-merge, or squash-merge outright + pull-requests: write # read labels, merge if: github.event_name != 'pull_request' steps: - name: Collect open Dependabot PRs