|
1 | 1 | name: Dependabot auto-merge |
2 | 2 |
|
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. |
| 3 | +# All the logic lives in py-canon, so a fix there reaches this repo on its |
| 4 | +# next run instead of waiting for someone to copy a file across. |
15 | 5 |
|
16 | 6 | on: |
17 | 7 | pull_request: |
|
21 | 11 | - cron: "37 */3 * * *" |
22 | 12 | workflow_dispatch: |
23 | 13 |
|
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 | 14 | permissions: {} |
28 | 15 |
|
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 |
39 | | - # Neither job checks out the repo, so gh has no git remote to infer from. |
40 | | - # Steps that pass a PR URL resolve the repo from the argument; `gh label |
41 | | - # create` takes no URL, so it fell back to git and exited 1 -- on every |
42 | | - # eligible PR the fleet ever saw. Set once here rather than per step: the |
43 | | - # next gh call added to either job is then correct by default. |
44 | | - GH_REPO: ${{ github.repository }} |
45 | | - |
46 | 16 | jobs: |
47 | | - classify: |
48 | | - name: Classify and arm |
49 | | - runs-on: ubuntu-latest |
50 | | - timeout-minutes: 10 |
51 | | - permissions: |
52 | | - contents: write # arm auto-merge |
53 | | - pull-requests: write # label, comment |
54 | | - if: >- |
55 | | - github.event_name == 'pull_request' && |
56 | | - github.event.pull_request.user.login == 'dependabot[bot]' |
57 | | - steps: |
58 | | - - name: Fetch Dependabot metadata |
59 | | - id: metadata |
60 | | - uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 |
61 | | - with: |
62 | | - github-token: ${{ secrets.GITHUB_TOKEN }} |
63 | | - |
64 | | - # Auto-merge everything except Python-ecosystem majors: |
65 | | - # - grouped PRs report the highest bump anywhere in the group (including |
66 | | - # transitive lockfile updates), so trust our own minor-and-patch groups |
67 | | - # - GitHub-Actions majors are CI-validated and low blast radius |
68 | | - # An unrecognised or empty update-type is NOT eligible, so metadata |
69 | | - # failures leave the PR open rather than merging it. Each branch is a |
70 | | - # full if/case, never `test && var=true`: under `bash -e` a failing test |
71 | | - # as the last statement of a step fails the whole step. |
72 | | - - name: Decide eligibility |
73 | | - id: gate |
74 | | - env: |
75 | | - ECOSYSTEM: ${{ steps.metadata.outputs.package-ecosystem }} |
76 | | - GROUP: ${{ steps.metadata.outputs.dependency-group }} |
77 | | - UPDATE_TYPE: ${{ steps.metadata.outputs.update-type }} |
78 | | - run: | |
79 | | - eligible=false |
80 | | - if [ "$ECOSYSTEM" = "github_actions" ]; then |
81 | | - eligible=true |
82 | | - fi |
83 | | - case "$GROUP" in |
84 | | - *minor-and-patch*) eligible=true ;; |
85 | | - esac |
86 | | - case "$UPDATE_TYPE" in |
87 | | - version-update:semver-minor|version-update:semver-patch) eligible=true ;; |
88 | | - esac |
89 | | - echo "ecosystem=$ECOSYSTEM group=$GROUP update-type=$UPDATE_TYPE eligible=$eligible" |
90 | | - echo "eligible=$eligible" >> "$GITHUB_OUTPUT" |
91 | | -
|
92 | | - # The label is what makes the scheduled sweep possible without it having |
93 | | - # to guess. Created with --force so the first run in a repo works. |
94 | | - - name: Record the verdict on the PR |
95 | | - if: steps.gate.outputs.eligible == 'true' |
96 | | - env: |
97 | | - PR_URL: ${{ github.event.pull_request.html_url }} |
98 | | - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
99 | | - run: | |
100 | | - gh label create "$ELIGIBLE_LABEL" --force --color 0e8a16 \ |
101 | | - --description "Auto-merge policy says yes; the sweep may arm or land this" |
102 | | - gh pr edit "$PR_URL" --add-label "$ELIGIBLE_LABEL" |
103 | | -
|
104 | | - # No `gh pr review --approve`: it fails outright where the repo has |
105 | | - # "Allow GitHub Actions to create and approve pull requests" off, which |
106 | | - # aborted the step before the merge ever ran. Our rulesets require status |
107 | | - # checks, not reviews, and GitHub's own documented example does not |
108 | | - # approve either. |
109 | | - - name: Enable auto-merge for eligible updates |
110 | | - if: steps.gate.outputs.eligible == 'true' |
111 | | - env: |
112 | | - PR_URL: ${{ github.event.pull_request.html_url }} |
113 | | - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
114 | | - run: | |
115 | | - if err=$(gh pr merge --auto --squash "$PR_URL" 2>&1); then |
116 | | - printf '%s\n' "$err" |
117 | | - exit 0 |
118 | | - fi |
119 | | - printf '%s\n' "$err" |
120 | | - # GitHub refuses to arm auto-merge on a PR it currently considers |
121 | | - # mergeable, and this job finishes in seconds -- often before the |
122 | | - # required check runs exist. Do not merge directly here: "clean" at |
123 | | - # t+4s does not mean the checks passed. The scheduled sweep picks it |
124 | | - # up once the checks are terminal, which is the safe moment. |
125 | | - case "$err" in |
126 | | - *"clean status"*|*"not mergeable"*|*"Auto merge is not allowed"*) |
127 | | - echo "::warning::auto-merge not armable yet; the sweep will retry" |
128 | | - exit 0 ;; |
129 | | - esac |
130 | | - exit 1 |
131 | | -
|
132 | | - - name: Flag ineligible updates for manual review |
133 | | - if: steps.gate.outputs.eligible != 'true' && github.event.action == 'opened' |
134 | | - env: |
135 | | - PR_URL: ${{ github.event.pull_request.html_url }} |
136 | | - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
137 | | - run: | |
138 | | - gh pr comment "$PR_URL" --body \ |
139 | | - "Left open for manual review — auto-merge covers GitHub-Actions updates, our minor-and-patch groups, and patch/minor Python bumps." |
140 | | -
|
141 | | - sweep: |
142 | | - name: Re-arm stranded PRs |
143 | | - runs-on: ubuntu-latest |
144 | | - timeout-minutes: 10 |
| 17 | + auto-merge: |
145 | 18 | permissions: |
146 | 19 | contents: write # arm auto-merge, or squash-merge outright |
147 | | - pull-requests: write # read labels, merge |
148 | | - if: github.event_name != 'pull_request' |
149 | | - steps: |
150 | | - - name: Collect open Dependabot PRs |
151 | | - env: |
152 | | - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
153 | | - run: | |
154 | | - gh pr list --author "app/dependabot" --state open --limit 100 \ |
155 | | - --json number,url,title,labels,autoMergeRequest,mergeStateStatus,statusCheckRollup,createdAt \ |
156 | | - > prs.json |
157 | | - python3 -c "import json;print('collected',len(json.load(open('prs.json'))),'open Dependabot PRs')" |
158 | | -
|
159 | | - # Required contexts, so a check that never reported can be named. A |
160 | | - # green rollup is not the same as a satisfied ruleset: on |
161 | | - # appeler/pranaam#10 all seven reported checks passed while the |
162 | | - # required `build` context never ran at all -- its workflow had been |
163 | | - # cancelled by a concurrency collision -- and the PR sat BLOCKED for |
164 | | - # weeks looking entirely green. Counting reported checks cannot see |
165 | | - # that; comparing against the requirement can. |
166 | | - gh api "repos/${GH_REPO}/rulesets" --jq '.[].id' 2>/dev/null \ |
167 | | - | while read -r id; do |
168 | | - gh api "repos/${GH_REPO}/rulesets/${id}" --jq \ |
169 | | - '.rules[]? | select(.type=="required_status_checks") |
170 | | - | .parameters.required_status_checks[].context' 2>/dev/null |
171 | | - done | sort -u > required.txt || true |
172 | | - echo "required contexts: $(tr '\n' ' ' < required.txt)" |
173 | | -
|
174 | | - # Decide per PR, print one line for every one of them, and act. Check |
175 | | - # state is read from statusCheckRollup rather than from mergeStateStatus |
176 | | - # alone: CLEAN is GitHub's opinion about mergeability, and this job needs |
177 | | - # the stronger fact that every check has reached a terminal state and |
178 | | - # none of them failed before it will merge anything directly. |
179 | | - - name: Decide what to do with each |
180 | | - id: plan |
181 | | - run: | |
182 | | - python3 - <<'PY' > actions.txt |
183 | | - import json, os, datetime as dt |
184 | | -
|
185 | | - TERMINAL_OK = {"SUCCESS", "SKIPPED", "NEUTRAL"} |
186 | | - label = os.environ["ELIGIBLE_LABEL"] |
187 | | - stale_after = dt.timedelta(hours=float(os.environ["STALE_AFTER_HOURS"])) |
188 | | - now = dt.datetime.now(dt.UTC) |
189 | | -
|
190 | | - def check_state(pr): |
191 | | - """Terminal-and-green, still-running, or failing.""" |
192 | | - rollup = pr.get("statusCheckRollup") or [] |
193 | | - if not rollup: |
194 | | - return "no-checks" |
195 | | - states = [] |
196 | | - for c in rollup: |
197 | | - # CheckRun reports status/conclusion; StatusContext reports state. |
198 | | - if c.get("__typename") == "StatusContext" or "state" in c: |
199 | | - states.append(c.get("state") or "PENDING") |
200 | | - elif (c.get("status") or "").upper() != "COMPLETED": |
201 | | - states.append("PENDING") |
202 | | - else: |
203 | | - states.append((c.get("conclusion") or "PENDING").upper()) |
204 | | - if any(s == "PENDING" for s in states): |
205 | | - return "running" |
206 | | - return "green" if all(s in TERMINAL_OK for s in states) else "failing" |
207 | | -
|
208 | | - def never_reported(pr): |
209 | | - """Required contexts with no check run at all on this PR.""" |
210 | | - seen = {c.get("name") or c.get("context") for c in |
211 | | - (pr.get("statusCheckRollup") or [])} |
212 | | - return sorted(required - seen) |
213 | | -
|
214 | | - try: |
215 | | - required = {ln.strip() for ln in open("required.txt") if ln.strip()} |
216 | | - except OSError: |
217 | | - required = set() |
218 | | -
|
219 | | - for pr in json.load(open("prs.json")): |
220 | | - n = pr["number"] |
221 | | - names = {l["name"] for l in pr.get("labels") or []} |
222 | | - age = now - dt.datetime.fromisoformat(pr["createdAt"].replace("Z", "+00:00")) |
223 | | - if label not in names: |
224 | | - verdict, act = "ineligible (no policy label)", "none" |
225 | | - elif pr.get("autoMergeRequest"): |
226 | | - verdict, act = "already armed", "none" |
227 | | - elif pr["mergeStateStatus"] in {"DIRTY", "BLOCKED", "DRAFT"}: |
228 | | - # Name the missing requirement rather than only its symptom: |
229 | | - # "BLOCKED" sends a reader looking for a failing check that |
230 | | - # does not exist. |
231 | | - absent = never_reported(pr) |
232 | | - reason = (f"required never ran: {','.join(absent)}" if absent |
233 | | - else pr["mergeStateStatus"]) |
234 | | - verdict, act = f"not mergeable ({reason})", "none" |
235 | | - else: |
236 | | - state = check_state(pr) |
237 | | - verdict, act = { |
238 | | - "green": ("checks terminal and green", "merge"), |
239 | | - "running": ("checks still running", "arm"), |
240 | | - "failing": ("checks failing", "none"), |
241 | | - "no-checks": ("no checks reported", "none"), |
242 | | - }[state] |
243 | | - # Only shout when the sweep is unable to act. A PR being merged |
244 | | - # on this very run is not stranded, however old it is. |
245 | | - stale = act == "none" and label in names and age > stale_after |
246 | | - print(f"{n}\t{act}\t{verdict}\t{int(age.total_seconds()//3600)}h\t{int(stale)}") |
247 | | - PY |
248 | | - printf '%-6s %-6s %-32s %6s\n' "PR" "ACTION" "VERDICT" "AGE" |
249 | | - while IFS=$'\t' read -r n act verdict age stale; do |
250 | | - printf '%-6s %-6s %-32s %6s\n' "#$n" "$act" "$verdict" "$age" |
251 | | - if [ "$stale" = "1" ]; then |
252 | | - echo "::warning::PR #$n is eligible but still unmerged after ${age} — automation has not been able to land it" |
253 | | - fi |
254 | | - done < actions.txt |
255 | | -
|
256 | | - - name: Arm or land |
257 | | - env: |
258 | | - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
259 | | - run: | |
260 | | - acted=0 |
261 | | - while IFS=$'\t' read -r n act _rest; do |
262 | | - case "$act" in |
263 | | - arm) |
264 | | - echo "arming #$n" |
265 | | - gh pr merge "$n" --auto --squash || echo "::warning::could not arm #$n" |
266 | | - acted=$((acted + 1)) ;; |
267 | | - merge) |
268 | | - echo "merging #$n" |
269 | | - gh pr merge "$n" --squash --delete-branch || echo "::warning::could not merge #$n" |
270 | | - acted=$((acted + 1)) ;; |
271 | | - esac |
272 | | - done < actions.txt |
273 | | - echo "sweep acted on $acted PR(s)" |
| 20 | + pull-requests: write # label, comment, merge |
| 21 | + uses: gojiplus/py-canon/.github/workflows/reusable-dependabot-auto-merge.yml@v1 |
0 commit comments