From aae30f17804ac6b00e7c53719ba883692b6481c8 Mon Sep 17 00:00:00 2001 From: Srinivasa Consultants <78673153+dinakarjs@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:14:16 +0530 Subject: [PATCH] ci: gate Dependabot auto-merge by verified update type Signed-off-by: Srinivasa Dinakar Jainti --- .github/dependabot.yml | 8 +- .github/workflows/dependabot-auto-merge.yml | 27 ++++- tests/test_dependabot_auto_merge.py | 117 ++++++++++++++++++++ 3 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 tests/test_dependabot_auto_merge.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fd8726b7..58153371 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,8 +9,8 @@ updates: ignore: - 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-conformance: patterns: ["*"] @@ -26,8 +26,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 163ebbf2..b5367761 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/test_dependabot_auto_merge.py b/tests/test_dependabot_auto_merge.py new file mode 100644 index 00000000..9da3e1e7 --- /dev/null +++ b/tests/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 re +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + +WORKFLOW = Path(__file__).resolve().parents[1] / ".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") + # Extract this workflow's literal script without adding a YAML dependency + # to the reference library's development environment. + block = re.search(r"^ run: \|\n((?: [^\n]*\n)+)", WORKFLOW.read_text(), re.M) + assert block is not None, "auto-merge must retain its literal Bash block" + script = textwrap.dedent(block[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", script], + 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: + source = WORKFLOW.read_text() + assert re.search( + r"^on:\n pull_request:\n types: \[opened, reopened, synchronize\]\n" + r" branches: \[main\]\n\npermissions:", + source, + re.M, + ) + assert "user.login == 'dependabot[bot]'" in source + assert "head.repo.full_name == github.repository" in source + assert "permissions:\n contents: read\n" in source + assert " permissions:\n contents: write\n pull-requests: write\n" in source + assert re.search(r"uses: dependabot/fetch-metadata@[0-9a-f]{40} #", source) + assert "skip-verification" not in source + assert "skip-commit-verification" not in source + assert "checkout@" not in source