Skip to content

Commit 71e89b4

Browse files
soodokuclaude
andauthored
Consume py-canon's auto-merge instead of copying it (#24)
The arming fix in gojiplus/py-canon#21 reaches only repositories that consume the reusable workflow. Eight held copies, so it would have reached one. Verified against py-canon's reusable before switching: it is a superset of this copy, same coverage of the label, the sweep, the stale warning, the missing-required-context naming and GH_REPO. Nothing is lost by delegating. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 9ec7f66 commit 71e89b4

1 file changed

Lines changed: 5 additions & 257 deletions

File tree

Lines changed: 5 additions & 257 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,7 @@
11
name: Dependabot auto-merge
22

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.
155

166
on:
177
pull_request:
@@ -21,253 +11,11 @@ on:
2111
- cron: "37 */3 * * *"
2212
workflow_dispatch:
2313

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.
2714
permissions: {}
2815

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-
4616
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:
14518
permissions:
14619
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

Comments
 (0)