#!/usr/bin/env python3 """Does this pull request's own diff touch any path its workflow filters on? Forgejo matches a workflow's `paths:` filter against the **push delta**, not the pull request's diff. A rebase moves a branch across every commit main has gained, so its push delta touches nearly every path and every path-filtered workflow fires. Measured on this repo: a jcp-only change pushed to a fresh branch ran 2 workflows; the same change force-pushed after a rebase ran 15, including JPEG-AI whole decode and wai-pack on three platforms. That is what fills the queue, and it delays everyone's real jobs behind irrelevant ones. This reads the path list out of the workflow file itself — one source of truth, rather than a third copy of the list to drift from the two `on:` blocks — and compares it against `git diff --name-only BASE...HEAD`. pr-touches-paths.py Prints `true` or `false`. **Fails open**: anything it cannot determine prints `true`, because a wrong `false` silently skips a test and a wrong `true` only costs runner time. A gate that guesses must guess toward running. """ import re import subprocess import sys from pathlib import Path def paths_of(workflow: Path) -> list[str]: """The union of every `paths:` list under `on:` in this workflow. Parsed rather than imported from a library: the runner image has no PyYAML guaranteed, and the shape here is a flat `paths:` followed by `- 'glob'` lines. A list this parser fails to read yields none, which fails open. """ globs: list[str] = [] in_paths = False for line in workflow.read_text().splitlines(): if re.match(r"\s*paths(-ignore)?:\s*$", line): in_paths = True continue if in_paths: stripped = line.strip() # A comment or a blank line inside the list does not end it. (It used to: # every glob after a list's first comment was dropped, and a pull request # touching only those paths was answered `false`.) if not stripped or stripped.startswith("#"): continue m = re.match(r"\s*-\s*'([^']+)'\s*(#.*)?$", line) or re.match(r'\s*-\s*"([^"]+)"\s*(#.*)?$', line) if m: globs.append(m.group(1)) continue if stripped.startswith("- "): globs.append(re.sub(r"\s+#.*$", "", stripped[2:]).strip()) continue in_paths = False return globs def matches(path: str, glob: str) -> bool: """`glob` in the Actions sense: `**` spans separators, `*` does not.""" rx = [] i = 0 while i < len(glob): if glob.startswith("**", i): rx.append(".*") i += 2 elif glob[i] == "*": rx.append("[^/]*") i += 1 elif glob[i] == "?": rx.append("[^/]") i += 1 else: rx.append(re.escape(glob[i])) i += 1 return re.fullmatch("".join(rx), path) is not None def main() -> None: if len(sys.argv) != 4: print("true") # cannot tell: run it return workflow, base, head = Path(sys.argv[1]), sys.argv[2], sys.argv[3] if not workflow.is_file() or not base or not head: print("true") return globs = paths_of(workflow) if not globs: # No filter, or a shape this parser does not read: the workflow wanted # to run on everything, or we cannot say it did not. print("true") return try: out = subprocess.run( ["git", "diff", "--name-only", f"{base}...{head}"], capture_output=True, text=True, check=True, ).stdout except (subprocess.CalledProcessError, OSError): print("true") return changed = [l for l in out.splitlines() if l.strip()] if not changed: print("true") return hit = any(matches(p, g) for p in changed for g in globs) print("true" if hit else "false") if __name__ == "__main__": main()