|
| 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 |
0 commit comments