From 9037ab8754e89145a28b9c234984d356a2cbadac Mon Sep 17 00:00:00 2001 From: Jay Hesselberth Date: Mon, 7 Sep 2026 07:27:57 -0600 Subject: [PATCH] ci: merge dependabot PRs once CI has passed on them 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. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CdbVo6UnMuYNmt2D7tjT3B --- .github/workflows/dependabot-auto-merge.yml | 115 ++++++++++++++++++++ .github/workflows/escapepod-sync.yml | 13 ++- 2 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/dependabot-auto-merge.yml diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..07477ee --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,115 @@ +name: Dependabot auto-merge + +# Merges a dependabot PR once CI has actually passed on it. +# +# 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 +# (`allow_auto_merge: false`, `main` unprotected), and with no required checks +# GitHub's auto-merge merges *immediately* rather than waiting for CI — worse +# than merging by hand. Triggering on `workflow_run` instead means "CI finished +# and it was green" is the entry condition, by construction. +# +# Two updates are deliberately left for a person: +# +# escapepod-signal its PyPI twin `escapepod` must move in the same commit +# (see the escapepod section of CLAUDE.md, and #193). Merging +# the crate alone puts main in exactly the skew that +# escapepod-sync.yml then has to repair. +# major bumps a major is an API break by declaration; CI passing means +# the suite still runs, not that the semantics held. + +on: + workflow_run: + workflows: ["CI"] + types: [completed] + +permissions: + contents: write + pull-requests: write + +concurrency: + group: dependabot-auto-merge-${{ github.event.workflow_run.head_sha }} + cancel-in-progress: false + +jobs: + merge: + name: Merge if green and in policy + # `conclusion` gates on CI having passed; `actor` is a cheap pre-filter, and + # the authoritative author check is against the PR itself below. + if: > + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.actor.login == 'dependabot[bot]' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Resolve the pull request for this run + id: pr + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + # workflow_run.pull_requests is empty for forks, so resolve by SHA. + number=$(gh api "repos/${REPO}/commits/${HEAD_SHA}/pulls" \ + --jq '[.[] | select(.state == "open")] | first | .number // empty') + if [ -z "$number" ]; then + echo "No open PR for ${HEAD_SHA}; nothing to merge." + echo "found=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + author=$(gh pr view "$number" --repo "$REPO" --json author -q .author.login) + if [ "$author" != "app/dependabot" ]; then + echo "PR #${number} is authored by ${author}, not dependabot." + echo "found=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "number=${number}" >> "$GITHUB_OUTPUT" + echo "found=true" >> "$GITHUB_OUTPUT" + + - name: Check the update against policy + id: policy + if: steps.pr.outputs.found == 'true' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + NUMBER: ${{ steps.pr.outputs.number }} + run: | + set -euo pipefail + title=$(gh pr view "$NUMBER" --repo "$REPO" --json title -q .title) + echo "title: ${title}" + + # "bump from to " — the shape dependabot always uses. + name=$(sed -n 's/.*bump \([^ ]*\) from .* to .*/\1/p' <<<"$title") + from=$(sed -n 's/.*bump [^ ]* from \([^ ]*\) to .*/\1/p' <<<"$title") + to=$(sed -n 's/.*bump [^ ]* from [^ ]* to \([^ ]*\).*/\1/p' <<<"$title") + echo "name=${name} from=${from} to=${to}" + + if [ -z "$name" ] || [ -z "$from" ] || [ -z "$to" ]; then + echo "::notice::Could not parse the update from the title; leaving it for a person." + echo "merge=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + + if [ "$name" = "escapepod-signal" ] || [ "$name" = "escapepod" ]; then + echo "::notice::${name} moves together with its twin; leaving it for a person." + echo "merge=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + + # Compare leading numeric components; tags carry a leading "v". + major_from=$(sed 's/^v//' <<<"$from" | cut -d. -f1) + major_to=$(sed 's/^v//' <<<"$to" | cut -d. -f1) + if [ "$major_from" != "$major_to" ]; then + echo "::notice::major bump ${from} -> ${to}; leaving it for a person." + echo "merge=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + + echo "merge=true" >> "$GITHUB_OUTPUT" + + - name: Squash merge + if: steps.pr.outputs.found == 'true' && steps.policy.outputs.merge == 'true' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + NUMBER: ${{ steps.pr.outputs.number }} + run: gh pr merge "$NUMBER" --repo "$REPO" --squash --delete-branch diff --git a/.github/workflows/escapepod-sync.yml b/.github/workflows/escapepod-sync.yml index ee67ec8..9967378 100644 --- a/.github/workflows/escapepod-sync.yml +++ b/.github/workflows/escapepod-sync.yml @@ -55,12 +55,13 @@ jobs: if: steps.check.outputs.skewed == 'true' && steps.check.outputs.actionable == 'true' env: TARGET: ${{ steps.check.outputs.target }} - # The guard on the hand-written entry. NOT `uv lock --check`, which also - # demands the lock match the running uv's serialization conventions -- - # this lock does not (it predates the emscripten markers), so that gate - # fails on an untouched checkout and would block every proposal. - # `--frozen` resolves the lock as written and fails if the entry is - # incoherent, which is the risk of editing it by hand. + # The guard on the hand-written entry. Deliberately not `uv lock + # --check`: that also demands the lock match the *running* uv's + # serialization conventions, so it fails on an untouched checkout + # whenever the runner's uv is newer than the one that wrote the lock + # (this lock has been in that state, and would have blocked every + # proposal). `--frozen` resolves the lock as written and fails only if + # the entry is incoherent, which is the actual risk of editing by hand. run: | set -euo pipefail uv export --frozen --no-emit-project --extra rust > /tmp/requirements.txt