From 29631e8d416dc3ce3be293975da78bbf592e80e1 Mon Sep 17 00:00:00 2001 From: Isaac Adjei Date: Mon, 6 Jul 2026 19:48:21 +0100 Subject: [PATCH] chore: make Dependabot auto-merge ecosystem-aware Small bumps and major GitHub Actions bumps auto-merge once CI passes, but major npm and pip bumps are held for manual review because a breaking runtime change could pass lint and build and still deploy to production. Adds the Repo maintenance workflow to delete merged branches the token could not clean up. Previously major bumps were skipped. --- .github/workflows/automerge-dependabot.yml | 92 ++++++++++++---------- .github/workflows/update-pr-branches.yml | 49 ++++++++++++ CHANGELOG.md | 5 ++ 3 files changed, 105 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/update-pr-branches.yml diff --git a/.github/workflows/automerge-dependabot.yml b/.github/workflows/automerge-dependabot.yml index 64ac84b..86d24bc 100644 --- a/.github/workflows/automerge-dependabot.yml +++ b/.github/workflows/automerge-dependabot.yml @@ -1,62 +1,72 @@ -# Auto-merge safe Dependabot pull requests after required checks pass. +# Enables auto-merge (squash) on Dependabot PRs and any PR labelled "automerge". +# Draft PRs are skipped. Small updates (patch and minor) auto-merge on any +# ecosystem. Major updates only auto-merge for GitHub Actions, where CI runs the +# action itself so a breaking bump goes red; major application-dependency bumps +# (npm, pip and the like) are held for manual review, because CI can pass while +# runtime behaviour breaks. CI must still pass before any merge lands. # -# Rules: -# - Dependabot author only -# - Semantic version patch or minor bump only -# - Uses GitHub auto-merge so required checks are still enforced -# - Deletes source branch after merge +# Branch deletion for merges the token could not clean up is handled by the +# scheduled Repo maintenance workflow. See update-pr-branches.yml for the detail. -name: Dependabot Auto-merge +name: Auto-merge Dependabot on: pull_request_target: - types: [opened, reopened, synchronize] + types: [opened, reopened, synchronize, ready_for_review, labeled] permissions: contents: write pull-requests: write jobs: - automerge: + enable-automerge: name: Auto-merge safe Dependabot PRs - if: github.actor == 'dependabot[bot]' runs-on: ubuntu-latest steps: - # Parse versions from the Dependabot PR title. - # Expected title pattern: - # "... from X.Y.Z to A.B.C ..." - - name: Detect safe semver bump - id: semver - shell: bash + - name: Fetch Dependabot metadata + id: meta + if: github.event.pull_request.user.login == 'dependabot[bot]' + uses: dependabot/fetch-metadata@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Enable auto-merge for eligible PRs + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_URL: ${{ github.event.pull_request.html_url }} + PR_IS_DRAFT: ${{ github.event.pull_request.draft }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + UPDATE_TYPE: ${{ steps.meta.outputs.update-type }} + ECOSYSTEM: ${{ steps.meta.outputs.package-ecosystem }} run: | - TITLE='${{ github.event.pull_request.title }}' - if [[ "$TITLE" =~ from[[:space:]]([0-9]+)\.([0-9]+)\.([0-9]+)[[:space:]]to[[:space:]]([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then - FROM_MAJOR="${BASH_REMATCH[1]}" - FROM_MINOR="${BASH_REMATCH[2]}" - TO_MAJOR="${BASH_REMATCH[4]}" - TO_MINOR="${BASH_REMATCH[5]}" - - # Patch/minor update means major version did not change. - if [[ "$FROM_MAJOR" == "$TO_MAJOR" ]]; then - echo "safe=true" >> "$GITHUB_OUTPUT" + # Never auto-merge drafts - they are explicitly not ready + if [ "$PR_IS_DRAFT" = "true" ]; then + echo "Draft PR - skipping auto-merge setup" + exit 0 + fi + + # Non-Dependabot PRs opt in with the automerge label + if [ "$PR_AUTHOR" != "dependabot[bot]" ]; then + if echo "$PR_LABELS" | grep -Eq '(^|,)automerge(,|$)'; then + gh pr merge --auto --squash --delete-branch "$PR_URL" else - echo "safe=false" >> "$GITHUB_OUTPUT" + echo "Auto-merge not enabled - add the automerge label for non-Dependabot PRs" fi - else - # If the title cannot be parsed safely, skip auto-merge. - echo "safe=false" >> "$GITHUB_OUTPUT" + exit 0 fi - # Enable auto-merge so GitHub merges only after required checks succeed. - - name: Enable auto-merge - if: steps.semver.outputs.safe == 'true' - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - gh pr merge "${{ github.event.pull_request.number }}" --auto --squash --delete-branch + # Dependabot: hold major bumps for application dependencies (anything + # that is not GitHub Actions), auto-merge everything else. + if [ "$UPDATE_TYPE" = "version-update:semver-major" ]; then + case "$ECOSYSTEM" in + github_actions|github-actions) + echo "Major GitHub Actions bump - CI runs the action, enabling auto-merge" ;; + *) + echo "Holding major $ECOSYSTEM bump for manual review" + exit 0 ;; + esac + fi - # Log when a PR is intentionally skipped. - - name: Skip unsafe update - if: steps.semver.outputs.safe != 'true' - run: echo "Skipping auto-merge because this is not a safe patch/minor bump." + gh pr merge --auto --squash --delete-branch "$PR_URL" diff --git a/.github/workflows/update-pr-branches.yml b/.github/workflows/update-pr-branches.yml new file mode 100644 index 0000000..1b15198 --- /dev/null +++ b/.github/workflows/update-pr-branches.yml @@ -0,0 +1,49 @@ +# Deletes merged PR branches that the merge itself failed to clean up. +# +# GitHub does not trigger other workflows' push or pull_request events for actions +# performed with a workflow's own GITHUB_TOKEN (anti-recursion safeguard), so the +# automerge job's squash-merge commits do not reliably fire this on push: main. A +# schedule trigger does not depend on what authored the previous event, so it is +# used as the reliable path; deleting an already-merged branch never pushes a commit +# to an open PR, so it cannot trigger further CI. + +name: Repo maintenance + +on: + push: + branches: [main] + schedule: + # Every 2 hours - cheap safety net for branch deletion only + - cron: "0 */2 * * *" + workflow_dispatch: {} + +permissions: + contents: write + pull-requests: write + +jobs: + maintain-branches: + runs-on: ubuntu-latest + steps: + - name: Delete head branches of merged PRs that still exist + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + run: | + # Recently merged PRs - their head branch should have been deleted at + # merge time, but the GITHUB_TOKEN restriction above means it often did not + merged_branches=$(gh api "repos/$REPO/pulls?state=closed&sort=updated&direction=desc&per_page=30" \ + --jq '[.[] | select(.merged_at != null and .head.repo.full_name == "'"$REPO"'") | .head.ref] | .[]') + + if [ -z "$merged_branches" ]; then + echo "No recently merged PRs found" + exit 0 + fi + + while read -r branch; do + [ "$branch" = "main" ] && continue + if gh api "repos/$REPO/branches/$branch" >/dev/null 2>&1; then + echo "Deleting stale merged branch: $branch" + gh api -X DELETE "repos/$REPO/git/refs/heads/$branch" || echo "Failed to delete $branch - may already be gone" + fi + done <<< "$merged_branches" diff --git a/CHANGELOG.md b/CHANGELOG.md index ab6e82e..f0debca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added - GitHub Sponsors added to `.github/FUNDING.yml` as first entry; order updated to github, buy_me_a_coffee, patreon +- `Repo maintenance` workflow (`update-pr-branches.yml`) to delete merged PR branches the merge could not clean up + +### Changed + +- Auto-merge is now ecosystem-aware: patch and minor Dependabot bumps and major GitHub Actions bumps auto-merge once CI passes, but major `npm` and `pip` bumps are held for manual review since a breaking runtime change could pass lint and build yet still deploy; previously major bumps were skipped entirely ## [2.3.0] - 2026-06-03