mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 20:26:33 +00:00
208 lines
6.8 KiB
Python
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()
|