Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

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())