ci: a rebase ran every path-filtered workflow, because paths: matches the push delta #191

Merged
dcharlot merged 1 commit from ci/pr-diff-path-gate into main 2026-10-09 02:48:49 -04:00
4 changed files with 232 additions and 2 deletions

View file

@ -47,8 +47,41 @@ on:
- '.github/workflows/wai-pack.yml'
jobs:
# Forgejo matches the `paths:` filters above against the **push delta**, not
# the pull request's diff. A rebase moves a branch across every commit main
# has gained, so its delta touches nearly every path and this workflow fires
# on changes that cannot affect it. 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, this one among them, putting three platforms of wai-pack and
# the JPEG-AI whole decode in a queue they had no business being in.
#
# So the filters above still gate the *trigger*, and this job gates the
# *work*, from the pull request's own diff. It reads the path list out of this
# file, so there is one list and not a third copy to drift. On a push to main
# there is no pull-request context, the shas are empty, and the gate answers
# `true` — a push to main runs everything, as before.
relevant:
name: wai-pack · does this change touch it
runs-on: linux
outputs:
run: ${{ steps.gate.outputs.run }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- id: gate
run: |
set -euo pipefail
r=$(python3 scripts/pr-touches-paths.py .github/workflows/wai-pack.yml \
"${{ github.event.pull_request.base.sha }}" \
"${{ github.event.pull_request.head.sha }}")
echo "this change touches wai-pack: $r"
echo "run=$r" >> "$GITHUB_OUTPUT"
legs:
name: wai-pack · ${{ matrix.os }}
needs: relevant
if: needs.relevant.outputs.run == 'true'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@ -112,6 +145,8 @@ jobs:
independent:
name: wai-pack · independent verifier (Python)
needs: relevant
if: needs.relevant.outputs.run == 'true'
runs-on: linux
steps:
- uses: actions/checkout@v5
@ -167,6 +202,8 @@ jobs:
media:
name: wai-pack · the floor through a media prober
needs: relevant
if: needs.relevant.outputs.run == 'true'
runs-on: linux
steps:
- uses: actions/checkout@v5
@ -198,6 +235,8 @@ jobs:
licences:
name: wai-pack · dependency licences
needs: relevant
if: needs.relevant.outputs.run == 'true'
runs-on: linux
steps:
- uses: actions/checkout@v5

View file

@ -2,8 +2,8 @@ name: workflow hygiene
# Properties of the workflows themselves, checked rather than remembered.
#
# Today that is one property: every workflow declares a concurrency group that
# varies by ref. None of them did until 2026-10-05, and the consequence was not
Two properties today. The first: every workflow declares a concurrency group
# that varies by ref. None of them did until 2026-10-05, and the consequence was not
# a slow build — it was that CI stopped being usable for anyone. A session
# pushing four versions of a branch queued four complete sets of runs, all of
# which had to drain; one PR's 21 contexts settled none in 70 minutes behind a
@ -12,6 +12,15 @@ name: workflow hygiene
# whole repo, invisibly: the workflow still passes, it just queues forever
# under load.
#
# **The path gate.** `scripts/pr-touches-paths.py` decides whether a
# path-filtered workflow's jobs do any work, from the pull request's own diff —
# because Forgejo matches a `paths:` filter against the push delta, so a rebase
# fires every path-filtered workflow in the repo (measured: a jcp-only change
# ran 2 workflows pushed fresh and 15 force-pushed after a rebase). A gate that
# decides whether tests run is load-bearing in the dangerous direction: a wrong
# `false` silently skips a workflow. Its self-test pins both directions and that
# every way of failing fails *open*.
#
# This job runs on `linux`, which is jetson-hub — deliberately not the dev Mac,
# which already serves 13 of this repo's 21 contexts through two runners. It
# needs no toolchain and finishes in seconds.
@ -23,6 +32,9 @@ on:
paths:
- '.github/workflows/**'
- 'scripts/check-workflow-concurrency.sh'
- 'scripts/check-workflow-concurrency-selftest.sh'
- 'scripts/pr-touches-paths.py'
- 'scripts/pr-touches-paths-selftest.sh'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@ -45,3 +57,8 @@ jobs:
- name: Every workflow is grouped per ref
run: ./scripts/check-workflow-concurrency.sh
# The path gate fails open in every failure mode, and still answers
# `false` for a diff that cannot affect the workflow.
- name: The path gate fails open (self-test)
run: ./scripts/pr-touches-paths-selftest.sh

View file

@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Self-test for pr-touches-paths.py, run by the workflow-hygiene job.
#
# A gate that decides whether tests run is itself load-bearing: a wrong `false`
# silently skips a whole workflow. These cases pin both directions and, most
# importantly, that every way of failing fails OPEN.
set -euo pipefail
cd "$(dirname "$0")/.."
G=scripts/pr-touches-paths.py
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
fail=0
check() { # name expected actual
if [ "$2" = "$3" ]; then printf ' ok %s\n' "$1"
else printf ' FAIL %s: expected %s, got %s\n' "$1" "$2" "$3"; fail=1; fi
}
# A throwaway repo, so the diffs are real git diffs and not a mock.
repo="$tmp/r"; mkdir -p "$repo/wai/wai-pack" "$repo/jcp"
git -C "$repo" init -q
git -C "$repo" config user.email t@e; git -C "$repo" config user.name t
echo x > "$repo/jcp/SPEC.md"; echo y > "$repo/wai/wai-pack/lib.rs"
git -C "$repo" add -A; git -C "$repo" commit -qm base
BASE=$(git -C "$repo" rev-parse HEAD)
echo changed > "$repo/jcp/SPEC.md"
git -C "$repo" add -A; git -C "$repo" commit -qm jcp-only
JCP=$(git -C "$repo" rev-parse HEAD)
echo changed > "$repo/wai/wai-pack/lib.rs"
git -C "$repo" add -A; git -C "$repo" commit -qm wai-too
WAI=$(git -C "$repo" rev-parse HEAD)
wf="$tmp/w.yml"
cat > "$wf" <<'Y'
on:
pull_request:
paths:
- 'wai/wai-pack/**'
Y
run() { ( cd "$repo" && python3 "$OLDPWD/$G" "$1" "$2" "$3" ); }
check "a jcp-only diff does not touch a wai filter" false "$(run "$wf" "$BASE" "$JCP")"
check "a diff that touches the filter runs" true "$(run "$wf" "$BASE" "$WAI")"
# Every failure mode must fail OPEN.
check "a missing workflow file" true "$(run "$tmp/nope.yml" "$BASE" "$JCP")"
check "a workflow with no filter" true "$(printf 'on:\n pull_request:\n' > "$tmp/nofilter.yml"; run "$tmp/nofilter.yml" "$BASE" "$JCP")"
check "an empty base sha" true "$(run "$wf" "" "$JCP")"
check "a sha git cannot resolve" true "$(run "$wf" "$BASE" deadbeefdeadbeefdeadbeefdeadbeefdeadbeef)"
check "too few arguments" true "$( cd "$repo" && python3 "$OLDPWD/$G" "$wf" )"
check "an empty diff" true "$(run "$wf" "$JCP" "$JCP")"
# `**` spans separators; `*` does not.
cat > "$tmp/star.yml" <<'Y'
on:
pull_request:
paths:
- 'jcp/*.md'
Y
# Only the NESTED file changes, so `jcp/*.md` must not match it. (A top-level
# .md in the same commit would match legitimately and prove nothing — which is
# how this case failed the first time it was written.)
mkdir -p "$repo/jcp/sub"; echo z > "$repo/jcp/sub/x.md"
git -C "$repo" add -A >/dev/null; git -C "$repo" commit -qm sub >/dev/null
SUB=$(git -C "$repo" rev-parse HEAD)
check "a single star does not span a separator" false "$(run "$tmp/star.yml" "$WAI" "$SUB")"
# And it does match a file at that level, so the glob is not simply never matching.
echo z > "$repo/jcp/top.md"
git -C "$repo" add -A >/dev/null; git -C "$repo" commit -qm top >/dev/null
check "a single star matches at its own level" true "$(run "$tmp/star.yml" "$SUB" "$(git -C "$repo" rev-parse HEAD)")"
[ "$fail" = 0 ] && echo "pr-touches-paths self-test: all cases pass" || { echo "pr-touches-paths self-test FAILED"; exit 1; }

104
scripts/pr-touches-paths.py Executable file
View file

@ -0,0 +1,104 @@
#!/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 <workflow.yml> <base-sha> <head-sha>
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:
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 line.strip().startswith("- "):
globs.append(line.strip()[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()