-
Notifications
You must be signed in to change notification settings - Fork 2
Re-arm Dependabot PRs that GitHub silently disarmed #18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,16 +1,53 @@ | ||
| 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. | ||
|
|
||
| permissions: | ||
| contents: write | ||
| pull-requests: write | ||
| 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: | ||
|
|
||
| # 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 | ||
| # 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]' | ||
| 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]' | ||
| steps: | ||
| - name: Fetch Dependabot metadata | ||
| id: metadata | ||
|
|
@@ -46,6 +83,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 +114,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 +131,107 @@ 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 | ||
| 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 | ||
| 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" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the sweep sees terminal-green checks, this immediately merges using the repository Useful? React with 👍 / 👎. |
||
| acted=$((acted + 1)) ;; | ||
| esac | ||
| done < actions.txt | ||
| echo "sweep acted on $acted PR(s)" | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On every eligible PR, this command attempts to create or update a repository label, but the workflow's explicit
permissionsblock omitsissues: write; unspecified permissions are set to none. GitHub's Create a label endpoint requires Issues write permission, andgh label create --helpconfirms that--forceupdates an existing label too, so this fails even after the label exists. Because the shell exits on that failure, neither the label nor auto-merge is applied, and the sweep subsequently treats the PR as ineligible.Useful? React with 👍 / 👎.