Files
computer-playbook/tests/integration/test_dependabot_coverage.py
T

208 lines
6.8 KiB
Python

import fnmatch
import os
import unittest
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parents[2]
DEPENDABOT_PATH = REPO_ROOT / ".github" / "dependabot.yml"
# Directories skipped when walking the repository
SKIP_DIRS = {
".git",
".venv",
"venv",
"__pycache__",
"node_modules",
".tox",
"build",
"dist",
}
# Hidden directories that may contain real dependency manifests
SCANNED_HIDDEN_DIRS = {
".github",
".devcontainer",
}
# File suffixes that indicate generated/template files, not real dependency files
SKIP_SUFFIXES = (".j2",)
# Mapping: Dependabot ecosystem name → indicator filename patterns (fnmatch-style)
ECOSYSTEM_FILENAME_INDICATORS: dict[str, list[str]] = {
"pip": [
"pyproject.toml",
"requirements.txt",
"requirements*.txt",
"setup.py",
"setup.cfg",
"Pipfile",
],
"uv": ["uv.lock"],
"npm": ["package.json"],
"bun": ["bun.lockb"],
"cargo": ["Cargo.toml"],
"rust-toolchain": ["rust-toolchain.toml", "rust-toolchain"],
"composer": ["composer.json"],
"gomod": ["go.mod"],
"maven": ["pom.xml"],
"gradle": ["build.gradle", "build.gradle.kts"],
"nuget": ["*.csproj", "*.fsproj", "*.vbproj"],
"dotnet-sdk": ["global.json"],
"mix": ["mix.exs"],
"swift": ["Package.swift"],
"pub": ["pubspec.yaml"],
"conda": ["environment.yml"],
"julia": ["Project.toml", "JuliaProject.toml"],
"elm": ["elm.json"],
"bazel": ["MODULE.bazel", "WORKSPACE", "WORKSPACE.bazel"],
"vcpkg": ["vcpkg.json"],
"gitsubmodule": [".gitmodules"],
"bundler": ["Gemfile", "gems.rb"],
"terraform": ["*.tf"],
"opentofu": ["*.tf"],
"helm": ["Chart.yaml"],
"pre-commit": [".pre-commit-config.yaml"],
"docker": ["Dockerfile", "*.dockerfile", "Dockerfile.*"],
"docker-compose": [
"docker-compose.yml",
"docker-compose.yaml",
"compose.yml",
"compose.yaml",
],
}
# Mapping: Dependabot ecosystem name → repository-relative path patterns
ECOSYSTEM_PATH_INDICATORS: dict[str, list[str]] = {
"github-actions": [
".github/workflows/*.yml",
".github/workflows/*.yaml",
"action.yml",
"action.yaml",
],
"devcontainers": [
".devcontainer/devcontainer.json",
".devcontainer/*/devcontainer.json",
],
}
# Ecosystem groups where any one active member covers the shared file type.
# E.g. terraform and opentofu both consume *.tf — either being active is sufficient.
EQUIVALENT_ECOSYSTEMS: list[frozenset[str]] = [
frozenset({"terraform", "opentofu"}),
]
def _dir_covers(dep_dir: str, file_rel_dir: str) -> bool:
"""Return True if a Dependabot directory pattern covers a file's relative directory.
dep_dir is as written in dependabot.yml (e.g. '/', '/**', '/roles/foo/').
file_rel_dir is the directory of the file relative to REPO_ROOT (e.g. 'roles/foo').
An empty file_rel_dir means the file is at the repo root.
For many ecosystems GitHub's Dependabot treats '/' as "search recursively
from root", which means it covers all sub-directories too.
"""
norm_dep = "/" + dep_dir.strip("/") if dep_dir.strip("/") else "/"
norm_file = "/" + file_rel_dir.strip("/") if file_rel_dir else "/"
# '/' and '/**' are both "cover the entire repository"
if norm_dep in ("/", "/**"):
return True
return norm_file == norm_dep or norm_file.startswith(norm_dep + "/")
def _load_active_entries() -> list[dict]:
with open(DEPENDABOT_PATH) as fh:
data = yaml.safe_load(fh)
return data.get("updates", [])
def _get_entry_dirs(entry: dict) -> list[str]:
if "directories" in entry:
return entry["directories"]
return [entry.get("directory", "/")]
def _matching_ecosystems(rel_file: str, filename: str) -> set[str]:
matched = {
ecosystem
for ecosystem, patterns in ECOSYSTEM_FILENAME_INDICATORS.items()
if any(fnmatch.fnmatch(filename, pattern) for pattern in patterns)
}
matched.update(
ecosystem
for ecosystem, patterns in ECOSYSTEM_PATH_INDICATORS.items()
if any(fnmatch.fnmatch(rel_file, pattern) for pattern in patterns)
)
return matched
class TestDependabotCoverage(unittest.TestCase):
def test_all_dependency_files_are_covered(self):
"""Every dependency file found in the repository must be covered by an
active Dependabot entry. The test fails when a new ecosystem file is
added (e.g. a Gemfile or Cargo.toml inside a role) but the matching
ecosystem is still commented-out in .github/dependabot.yml."""
active = _load_active_entries()
# Build lookup: ecosystem → [directory patterns]
ecosystem_dirs: dict[str, list[str]] = {}
for entry in active:
eco = entry["package-ecosystem"]
ecosystem_dirs.setdefault(eco, []).extend(_get_entry_dirs(entry))
uncovered: list[str] = []
for dirpath, dirnames, filenames in os.walk(REPO_ROOT):
# Prune directories we never want to scan
dirnames[:] = [
d
for d in dirnames
if d not in SKIP_DIRS
and (not d.startswith(".") or d in SCANNED_HIDDEN_DIRS)
]
rel_dir = os.path.relpath(dirpath, REPO_ROOT)
rel_dir = "" if rel_dir == "." else rel_dir
for filename in filenames:
# Skip Jinja2 templates and other generated files
if any(filename.endswith(s) for s in SKIP_SUFFIXES):
continue
rel_file = os.path.join(rel_dir, filename) if rel_dir else filename
for ecosystem in sorted(_matching_ecosystems(rel_file, filename)):
# Collect all ecosystem names that are equivalent for this file
candidates: set[str] = {ecosystem}
for group in EQUIVALENT_ECOSYSTEMS:
if ecosystem in group:
candidates |= group
covered = any(
any(
_dir_covers(dep_dir, rel_dir)
for dep_dir in ecosystem_dirs.get(eco, [])
)
for eco in candidates
)
if not covered:
uncovered.append(f"{rel_file} → ecosystem: {ecosystem}")
if uncovered:
self.fail(
"The following dependency files exist but are NOT covered by an active"
" Dependabot entry in .github/dependabot.yml.\n"
"Enable the matching ecosystem or add the directory to an existing entry:\n\n"
+ "\n".join(f" {entry}" for entry in sorted(uncovered))
)
if __name__ == "__main__":
unittest.main()