#!/usr/bin/env python3 """Pre-commit hook: verify test paths referenced in GitHub workflows exist. GitHub Actions steps frequently invoke pytest on specific files or directories, e.g.:: pdm run python -m pytest tests/security/test_ssrf_validator.py -v When such a file is later renamed or deleted (as happened with tests/security/test_input_validation.py, removed in #4243), the workflow keeps pointing at the now-missing path. pytest then exits with code 5 ("no tests collected") and the CI gate fails for *every* PR that triggers it -- a failure unrelated to the triggering change and easy to miss until release time (#4411). This hook scans changed workflow YAML files for ``tests/...`` paths that are passed as arguments to ``pytest`` (files or directories) and fails if any do not exist on disk, so the drift is caught at commit time instead of in CI. Scope is deliberately limited to pytest arguments. Other ``tests/...`` references -- e.g. ``upload-artifact`` ``path:`` entries pointing at runtime-generated output dirs like ``tests/screenshots/`` -- are not validated, because those legitimately do not exist until a CI run creates them. False positives are further avoided by: - ignoring the part of a line after an inline ``#`` comment, - following backslash line-continuations so a multi-file pytest invocation is analysed as one command, and - skipping any path that is itself protected by a shell existence guard (``[ -f ]`` / ``[ -e ]`` / ``test -f ``) anywhere in the same file -- that is the established "legacy tests if they exist" fallback pattern, which is intentionally tolerant of absence. """ import re import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent WORKFLOW_DIR = REPO_ROOT / ".github" / "workflows" # A test path token: starts at "tests/", runs over word chars, dots, # dashes and slashes. Stops at whitespace, quotes, ":" (pytest node ids), # "*" (globs) etc. We only act on tokens that clearly denote a concrete # file (".py") or directory (trailing "/") -- globs and bare prefixes are # left alone because they cannot be validated by simple existence checks. PATH_TOKEN = re.compile(r"tests/[\w.\-/]+") # Paths shielded by a shell existence check, e.g. `if [ -f tests/x.py ]`. GUARD_PATTERN = re.compile(r"(?:\[\s*-[fe]|test\s+-[fe])\s+(tests/[\w.\-/]+)") def strip_inline_comment(line: str) -> str: """Drop everything from the first '#' onwards (YAML and shell comment).""" hash_index = line.find("#") return line if hash_index == -1 else line[:hash_index] def iter_logical_lines(lines: list[str]): """Yield (start_line_number, text) merging backslash continuations. Inline comments are stripped per physical line first, so a trailing "\\" inside a comment never joins lines. """ buffer = "" start = None for line_num, raw_line in enumerate(lines, 1): line = strip_inline_comment(raw_line).rstrip() if start is None: start = line_num if line.endswith("\\"): buffer += line[:-1] + " " else: yield start, buffer + line buffer = "" start = None if buffer: yield start, buffer def collect_guarded_paths(lines: list[str]) -> set[str]: guarded: set[str] = set() for line in lines: for match in GUARD_PATTERN.finditer(line): guarded.add(match.group(1)) return guarded def path_exists(token: str) -> bool: target = REPO_ROOT / token if token.endswith("/"): return target.is_dir() return target.is_file() def check_workflow(path: Path) -> list[tuple[int, str]]: """Return [(line_number, missing_path), ...] for a single workflow file.""" try: lines = path.read_text(encoding="utf-8").splitlines() except (UnicodeDecodeError, OSError): return [] guarded = collect_guarded_paths(lines) violations: list[tuple[int, str]] = [] for start, logical in iter_logical_lines(lines): if "pytest" not in logical: continue # Only inspect the portion that is (part of) a pytest invocation. args = logical[logical.index("pytest") :] for match in PATH_TOKEN.finditer(args): token = match.group(0) # Only validate concrete files / directories. if not (token.endswith(".py") or token.endswith("/")): continue if token in guarded: continue if not path_exists(token): violations.append((start, token)) return violations def main() -> int: args = [Path(a) for a in sys.argv[1:]] if args: files = [ p for p in args if p.suffix in (".yml", ".yaml") and "workflows" in p.parts ] else: files = sorted(WORKFLOW_DIR.glob("*.y*ml")) all_violations: list[tuple[Path, int, str]] = [] for wf in files: for line_num, token in check_workflow(wf): all_violations.append((wf, line_num, token)) if all_violations: print("āŒ WORKFLOW REFERENCES A NON-EXISTENT TEST PATH") print("=" * 60) print("A workflow invokes pytest on a path that does not exist.") print("pytest exits with code 5 (no tests collected) on a missing") print("target, which fails the CI gate for every PR that runs it.") print("=" * 60) for wf, line_num, token in all_violations: try: rel = wf.relative_to(REPO_ROOT) except ValueError: rel = wf print(f"\nšŸ“„ {rel}") print(f" Line {line_num}: {token}") print("\n" + "=" * 60) print("FIX:") print("- Update the path to the file/directory's new location, or") print("- Remove the step if the tests were intentionally deleted, or") print("- Guard optional paths with `if [ -f ]; then ...`") print("=" * 60) return 1 return 0 if __name__ == "__main__": sys.exit(main())