7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
169 lines
5.9 KiB
Python
Executable File
169 lines
5.9 KiB
Python
Executable File
#!/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 <path> ]`` / ``[ -e <path> ]`` / ``test -f <path>``) 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 <path> ]; then ...`")
|
|
print("=" * 60)
|
|
return 1
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|