Files
2026-08-16 20:10:03 +01:00

209 lines
7.7 KiB
Python

#!/usr/bin/env python3
"""Apply a GitHub `paths:` filter inside a job instead of on the trigger.
Why this exists
---------------
A workflow that is skipped by a trigger-level `paths:` filter reports **no
conclusion at all**. GitHub's own documentation is explicit that "checks
associated with that workflow will remain in a 'Pending' state", and a required
check that stays pending blocks a pull request from entering a merge queue and
blocks the merge group from ever merging.
The `merge_group` event makes this worse: it supports `types:` and `branches:`
filters only -- `paths:` is not available -- so a trigger-level filter cannot
even be mirrored onto the queue side.
The fix is to move the filter one level down. The workflow triggers
unconditionally, a cheap `changes` job runs this script to decide whether the
expensive jobs are relevant, and an always-running gate job reports the single
conclusion the merge queue waits on. Jobs skipped by an `if:` conditional are
fine here because they are never themselves the required check.
Usage
-----
git diff --name-only -z "$BASE" HEAD | python3 .github/scripts/changed-paths.py
Reads the pattern list from `$PATTERNS` (override with `--patterns-env`), one
pattern per line, in the same syntax and with the same ordering semantics as a
workflow's `paths:` list. Writes `run=true` or `run=false` to stdout and, when
`$GITHUB_OUTPUT` is set, appends it there as a step output.
Pass `-z` to the diff. Git quotes any path outside ASCII under its default
`core.quotePath=true`, so plain `--name-only` reports `apps/web/café.ts` as the
literal 24-character string `"apps/web/caf\303\251.ts"`. That matches no
pattern, so a pull request touching only such a file selects nothing, every job
is skipped, and the gate reports success having tested none of it -- the exact
silent pass this whole mechanism exists to prevent. `-z` emits raw bytes with a
NUL after each path and suppresses the quoting.
Stdin is split on NUL when it contains one and on newlines otherwise, so both
forms work. The detection is not ambiguous: `-z` terminates every path with a
NUL, so any non-empty `-z` output contains at least one.
Pattern syntax
--------------
Supported: `*` (any run of characters except `/`), `**` (any run including
`/`), `?` (one character except `/`), and a leading `!` for negation. A file's
verdict is decided by the LAST pattern that matches it, which is what GitHub
does, so `!**/*.md` after `apps/web/**` excludes Markdown under `apps/web`.
Deliberately unsupported: `+`, `[...]` character classes, and `{...}` braces.
GitHub accepts the first two; this script rejects any pattern containing them
rather than silently matching them as literals, because a filter that quietly
stops matching is exactly the failure this file exists to prevent.
"""
from __future__ import annotations
import argparse
import os
import re
import sys
# Characters GitHub's filter-pattern syntax gives a meaning that this script
# does not implement. Rejected loudly rather than treated as literals.
UNSUPPORTED_METACHARACTERS = ("+", "[", "]", "{", "}")
def compile_pattern(pattern: str) -> re.Pattern[str]:
"""Translate one GitHub filter pattern into an anchored regex.
`**` is translated before `*` so that the single-segment wildcard never
consumes half of a double one.
"""
parts: list[str] = []
index = 0
while index < len(pattern):
char = pattern[index]
if char == "*":
if pattern.startswith("**", index):
parts.append(".*")
index += 2
else:
parts.append("[^/]*")
index += 1
elif char == "?":
parts.append("[^/]")
index += 1
else:
parts.append(re.escape(char))
index += 1
return re.compile("".join(parts) + r"\Z")
class Filter:
"""An ordered `paths:` list, evaluated last-match-wins per file."""
def __init__(self, patterns: list[str]) -> None:
self.rules: list[tuple[bool, re.Pattern[str]]] = []
for raw in patterns:
pattern = raw.strip()
# Blank lines and `#` comments keep the YAML block readable.
if not pattern or pattern.startswith("#"):
continue
included = True
if pattern.startswith("!"):
included = False
pattern = pattern[1:]
# A bare `!` compiles to a regex matching only the empty string,
# which no real path is. It would append a rule that can never fire
# and read like an exclusion that works.
if not pattern:
raise ValueError(
f"pattern {raw!r} is a bare negation with no path. Use "
"'!path/pattern' to exclude specific files."
)
found = [c for c in UNSUPPORTED_METACHARACTERS if c in pattern]
if found:
raise ValueError(
f"pattern {raw!r} uses unsupported metacharacter(s) "
f"{''.join(found)!r}. changed-paths.py implements *, ** and ? "
"only; extend it (and its tests) before using more."
)
self.rules.append((included, compile_pattern(pattern)))
if not self.rules:
raise ValueError("no patterns were supplied")
def includes(self, path: str) -> bool:
"""Whether `path` survives the filter. Later patterns win."""
verdict = False
for included, regex in self.rules:
if regex.match(path):
verdict = included
return verdict
def matches_any(self, paths: list[str]) -> bool:
return any(self.includes(path) for path in paths)
def read_changed_paths(data: bytes) -> list[str]:
"""Split a diff's path list, preferring the NUL-delimited form.
Taken as bytes rather than text because `git diff -z` emits raw filesystem
bytes, which need not be valid UTF-8. `surrogateescape` keeps an
undecodable name intact and matchable instead of failing the step.
"""
separator = b"\0" if b"\0" in data else b"\n"
return [
decoded
for chunk in data.split(separator)
if (decoded := chunk.decode("utf-8", errors="surrogateescape").strip())
]
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--patterns-env",
default="PATTERNS",
help="environment variable holding the newline-separated pattern list",
)
args = parser.parse_args(argv)
raw_patterns = os.environ.get(args.patterns_env)
if raw_patterns is None:
print(
f"::error::{args.patterns_env} is unset; changed-paths.py has no "
"filter to apply.",
file=sys.stderr,
)
return 2
try:
path_filter = Filter(raw_patterns.splitlines())
except ValueError as error:
print(f"::error::{error}", file=sys.stderr)
return 2
changed = read_changed_paths(sys.stdin.buffer.read())
run = path_filter.matches_any(changed)
# The matching subset is the useful half of the log: "no relevant changes"
# is indistinguishable from "the filter is broken" without it.
if run:
print("Relevant changed files:")
for path in changed:
if path_filter.includes(path):
print(f" {path}")
else:
print(f"No relevant changes among {len(changed)} changed file(s).")
value = "true" if run else "false"
github_output = os.environ.get("GITHUB_OUTPUT")
if github_output:
with open(github_output, "a", encoding="utf-8") as handle:
handle.write(f"run={value}\n")
print(f"run={value}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))