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