Skip to content

Commit 783f45d

Browse files
ci: merge dependabot PRs once CI has passed on them (#249)
Not GitHub's own auto-merge: that needs `allow_auto_merge` on the repo and, to be worth anything, required status checks on main. This repo has neither, and with no required checks GitHub's auto-merge merges immediately rather than waiting for CI. Triggering on `workflow_run` makes "CI finished and it was green" the entry condition instead. Two updates stay with a person: escapepod-signal, whose PyPI twin has to move in the same commit, and any major bump, where CI passing means the suite still runs rather than that the semantics held. Claude-Session: https://claude.ai/code/session_01CdbVo6UnMuYNmt2D7tjT3B Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 94a8009 commit 783f45d

2 files changed

Lines changed: 122 additions & 6 deletions

File tree

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
name: Dependabot auto-merge
2+
3+
# Merges a dependabot PR once CI has actually passed on it.
4+
#
5+
# NOT GitHub's own auto-merge: that needs `allow_auto_merge` on the repo and,
6+
# to be worth anything, required status checks on `main`. This repo has neither
7+
# (`allow_auto_merge: false`, `main` unprotected), and with no required checks
8+
# GitHub's auto-merge merges *immediately* rather than waiting for CI — worse
9+
# than merging by hand. Triggering on `workflow_run` instead means "CI finished
10+
# and it was green" is the entry condition, by construction.
11+
#
12+
# Two updates are deliberately left for a person:
13+
#
14+
# escapepod-signal its PyPI twin `escapepod` must move in the same commit
15+
# (see the escapepod section of CLAUDE.md, and #193). Merging
16+
# the crate alone puts main in exactly the skew that
17+
# escapepod-sync.yml then has to repair.
18+
# major bumps a major is an API break by declaration; CI passing means
19+
# the suite still runs, not that the semantics held.
20+
21+
on:
22+
workflow_run:
23+
workflows: ["CI"]
24+
types: [completed]
25+
26+
permissions:
27+
contents: write
28+
pull-requests: write
29+
30+
concurrency:
31+
group: dependabot-auto-merge-${{ github.event.workflow_run.head_sha }}
32+
cancel-in-progress: false
33+
34+
jobs:
35+
merge:
36+
name: Merge if green and in policy
37+
# `conclusion` gates on CI having passed; `actor` is a cheap pre-filter, and
38+
# the authoritative author check is against the PR itself below.
39+
if: >
40+
github.event.workflow_run.conclusion == 'success' &&
41+
github.event.workflow_run.event == 'pull_request' &&
42+
github.event.workflow_run.actor.login == 'dependabot[bot]'
43+
runs-on: ubuntu-latest
44+
timeout-minutes: 10
45+
steps:
46+
- name: Resolve the pull request for this run
47+
id: pr
48+
env:
49+
GH_TOKEN: ${{ github.token }}
50+
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
51+
REPO: ${{ github.repository }}
52+
run: |
53+
set -euo pipefail
54+
# workflow_run.pull_requests is empty for forks, so resolve by SHA.
55+
number=$(gh api "repos/${REPO}/commits/${HEAD_SHA}/pulls" \
56+
--jq '[.[] | select(.state == "open")] | first | .number // empty')
57+
if [ -z "$number" ]; then
58+
echo "No open PR for ${HEAD_SHA}; nothing to merge."
59+
echo "found=false" >> "$GITHUB_OUTPUT"
60+
exit 0
61+
fi
62+
author=$(gh pr view "$number" --repo "$REPO" --json author -q .author.login)
63+
if [ "$author" != "app/dependabot" ]; then
64+
echo "PR #${number} is authored by ${author}, not dependabot."
65+
echo "found=false" >> "$GITHUB_OUTPUT"
66+
exit 0
67+
fi
68+
echo "number=${number}" >> "$GITHUB_OUTPUT"
69+
echo "found=true" >> "$GITHUB_OUTPUT"
70+
71+
- name: Check the update against policy
72+
id: policy
73+
if: steps.pr.outputs.found == 'true'
74+
env:
75+
GH_TOKEN: ${{ github.token }}
76+
REPO: ${{ github.repository }}
77+
NUMBER: ${{ steps.pr.outputs.number }}
78+
run: |
79+
set -euo pipefail
80+
title=$(gh pr view "$NUMBER" --repo "$REPO" --json title -q .title)
81+
echo "title: ${title}"
82+
83+
# "bump <name> from <a> to <b>" — the shape dependabot always uses.
84+
name=$(sed -n 's/.*bump \([^ ]*\) from .* to .*/\1/p' <<<"$title")
85+
from=$(sed -n 's/.*bump [^ ]* from \([^ ]*\) to .*/\1/p' <<<"$title")
86+
to=$(sed -n 's/.*bump [^ ]* from [^ ]* to \([^ ]*\).*/\1/p' <<<"$title")
87+
echo "name=${name} from=${from} to=${to}"
88+
89+
if [ -z "$name" ] || [ -z "$from" ] || [ -z "$to" ]; then
90+
echo "::notice::Could not parse the update from the title; leaving it for a person."
91+
echo "merge=false" >> "$GITHUB_OUTPUT"; exit 0
92+
fi
93+
94+
if [ "$name" = "escapepod-signal" ] || [ "$name" = "escapepod" ]; then
95+
echo "::notice::${name} moves together with its twin; leaving it for a person."
96+
echo "merge=false" >> "$GITHUB_OUTPUT"; exit 0
97+
fi
98+
99+
# Compare leading numeric components; tags carry a leading "v".
100+
major_from=$(sed 's/^v//' <<<"$from" | cut -d. -f1)
101+
major_to=$(sed 's/^v//' <<<"$to" | cut -d. -f1)
102+
if [ "$major_from" != "$major_to" ]; then
103+
echo "::notice::major bump ${from} -> ${to}; leaving it for a person."
104+
echo "merge=false" >> "$GITHUB_OUTPUT"; exit 0
105+
fi
106+
107+
echo "merge=true" >> "$GITHUB_OUTPUT"
108+
109+
- name: Squash merge
110+
if: steps.pr.outputs.found == 'true' && steps.policy.outputs.merge == 'true'
111+
env:
112+
GH_TOKEN: ${{ github.token }}
113+
REPO: ${{ github.repository }}
114+
NUMBER: ${{ steps.pr.outputs.number }}
115+
run: gh pr merge "$NUMBER" --repo "$REPO" --squash --delete-branch

.github/workflows/escapepod-sync.yml

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,13 @@ jobs:
5555
if: steps.check.outputs.skewed == 'true' && steps.check.outputs.actionable == 'true'
5656
env:
5757
TARGET: ${{ steps.check.outputs.target }}
58-
# The guard on the hand-written entry. NOT `uv lock --check`, which also
59-
# demands the lock match the running uv's serialization conventions --
60-
# this lock does not (it predates the emscripten markers), so that gate
61-
# fails on an untouched checkout and would block every proposal.
62-
# `--frozen` resolves the lock as written and fails if the entry is
63-
# incoherent, which is the risk of editing it by hand.
58+
# The guard on the hand-written entry. Deliberately not `uv lock
59+
# --check`: that also demands the lock match the *running* uv's
60+
# serialization conventions, so it fails on an untouched checkout
61+
# whenever the runner's uv is newer than the one that wrote the lock
62+
# (this lock has been in that state, and would have blocked every
63+
# proposal). `--frozen` resolves the lock as written and fails only if
64+
# the entry is incoherent, which is the actual risk of editing by hand.
6465
run: |
6566
set -euo pipefail
6667
uv export --frozen --no-emit-project --extra rust > /tmp/requirements.txt

0 commit comments

Comments
 (0)