5dcac3f259
Co-authored-by: Kandev Agent <agent@kandev.dev>
209 lines
7.7 KiB
Python
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:]))
|