diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3780f10..4660747 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,8 +10,8 @@ updates: - dependency-name: "*" update-types: - "version-update:semver-major" - # One PR per week for minor and patch updates. Majors still arrive - # one at a time so a breaking change is reviewed on its own. + # Group minor and patch updates into one weekly PR. + # Major Python updates remain ignored by the existing policy. groups: python: patterns: ["*"] diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 163ebbf..b536776 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -1,11 +1,11 @@ name: Dependabot auto-merge -# Queues auto-merge on Dependabot PRs. It approves nothing: the PR still needs -# a maintainer approval of the current head and every required check, and -# GitHub merges it only once both are satisfied. +# Queues auto-merge on classified minor/patch Dependabot PRs; approves nothing. +# A maintainer must still approve the current head and every required check must +# pass. GitHub merges only once the existing branch requirements are satisfied. on: pull_request: - types: [opened, reopened] + types: [opened, reopened, synchronize] branches: [main] permissions: @@ -19,8 +19,25 @@ jobs: contents: write pull-requests: write steps: + - name: Read verified Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} - name: Enable auto-merge - run: gh pr merge --auto --squash "$PR_URL" + shell: bash + run: | + # Check every dependency in a group; unknown types stay manual too. + if ! jq -e 'type == "array" and length > 0 and all(.[]; + .updateType == "version-update:semver-minor" or + .updateType == "version-update:semver-patch")' \ + <<< "$UPDATED_DEPENDENCIES" > /dev/null; then + echo "Leaving major or unclassified updates for manual handling." + exit 0 + fi + gh pr merge --auto --squash --match-head-commit "$PR_HEAD_SHA" "$PR_URL" env: + UPDATED_DEPENDENCIES: ${{ steps.metadata.outputs.updated-dependencies-json }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} PR_URL: ${{ github.event.pull_request.html_url }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/tests/unit/test_dependabot_auto_merge.py b/tests/unit/test_dependabot_auto_merge.py new file mode 100644 index 0000000..5ad8ed2 --- /dev/null +++ b/tests/unit/test_dependabot_auto_merge.py @@ -0,0 +1,117 @@ +"""Exercise the workflow's real shell gate without contacting GitHub.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml + +WORKFLOW = Path(__file__).resolve().parents[2] / ".github/workflows/dependabot-auto-merge.yml" + + +@pytest.mark.parametrize( + ("metadata", "allowed"), + [ + ([{"updateType": "version-update:semver-patch"}], True), + ([{"updateType": "version-update:semver-minor"}], True), + ([{"updateType": "version-update:semver-major"}], False), + ( + [ + {"updateType": "version-update:semver-minor"}, + {"updateType": "version-update:semver-patch"}, + ], + True, + ), + ( + [ + {"updateType": "version-update:semver-patch"}, + {"updateType": "version-update:semver-major"}, + ], + False, + ), + ([{"updateType": "version-update:semver-patch"}, {}], False), + ([{"updateType": None}], False), + ([{"updateType": "unknown"}], False), + ([{"updateType": "version-update:semver-patch"}, "unknown"], False), + ([], False), + ({"updateType": "version-update:semver-patch"}, False), + (None, False), + ("not metadata", False), + ], +) +def test_only_fully_classified_minor_patch_groups_queue( + tmp_path: Path, metadata: object, allowed: bool +) -> None: + _run_gate(tmp_path, json.dumps(metadata), allowed) + + +def test_malformed_metadata_never_queues(tmp_path: Path) -> None: + _run_gate(tmp_path, "{invalid", False) + + +def _run_gate(tmp_path: Path, metadata: str, allowed: bool) -> None: + if sys.platform != "linux": + pytest.skip("Bash/jq execution matches the workflow's Ubuntu runner") + workflow = yaml.safe_load(WORKFLOW.read_text()) + step = workflow["jobs"]["enable"]["steps"][-1] + gh = tmp_path / "gh" + output = tmp_path / "gh-args" + gh.write_text('#!/bin/sh\nprintf "%s\\n" "$@" > "$AUDIT_GH_ARGS"\n') + gh.chmod(0o755) + head = "a" * 40 + url = "https://github.com/example/repo/pull/1" + env = { + **os.environ, + "PATH": str(tmp_path) + os.pathsep + os.environ["PATH"], + "AUDIT_GH_ARGS": str(output), + "UPDATED_DEPENDENCIES": metadata, + "PR_HEAD_SHA": head, + "PR_URL": url, + "GH_TOKEN": "unused-local-test-token", + } + subprocess.run( + ["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", step["run"]], + env=env, + check=True, + capture_output=True, + text=True, + timeout=10, + ) + assert output.exists() is allowed + if allowed: + assert output.read_text().splitlines() == [ + "pr", + "merge", + "--auto", + "--squash", + "--match-head-commit", + head, + url, + ] + + +def test_workflow_keeps_event_and_permission_boundaries() -> None: + # BaseLoader retains YAML's literal "on" key instead of YAML 1.1's boolean. + workflow = yaml.load(WORKFLOW.read_text(), Loader=yaml.BaseLoader) + assert set(workflow["on"]) == {"pull_request"} + assert set(workflow["on"]["pull_request"]["types"]) == { + "opened", + "reopened", + "synchronize", + } + job = workflow["jobs"]["enable"] + assert "user.login == 'dependabot[bot]'" in job["if"] + assert "head.repo.full_name == github.repository" in job["if"] + assert job["permissions"] == {"contents": "write", "pull-requests": "write"} + metadata = job["steps"][0] + action, revision = metadata["uses"].split("@") + assert action == "dependabot/fetch-metadata" + assert len(revision) == 40 and all(c in "0123456789abcdef" for c in revision) + assert "skip-verification" not in metadata.get("with", {}) + assert "skip-commit-verification" not in metadata.get("with", {}) + assert all("checkout@" not in step.get("uses", "") for step in job["steps"])