ci: a rebase ran every path-filtered workflow, because paths: matches the push delta #191
4 changed files with 232 additions and 2 deletions
39
.github/workflows/wai-pack.yml
vendored
39
.github/workflows/wai-pack.yml
vendored
|
|
@ -47,8 +47,41 @@ on:
|
||||||
- '.github/workflows/wai-pack.yml'
|
- '.github/workflows/wai-pack.yml'
|
||||||
|
|
||||||
jobs:
|
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:
|
legs:
|
||||||
name: wai-pack · ${{ matrix.os }}
|
name: wai-pack · ${{ matrix.os }}
|
||||||
|
needs: relevant
|
||||||
|
if: needs.relevant.outputs.run == 'true'
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|
@ -112,6 +145,8 @@ jobs:
|
||||||
|
|
||||||
independent:
|
independent:
|
||||||
name: wai-pack · independent verifier (Python)
|
name: wai-pack · independent verifier (Python)
|
||||||
|
needs: relevant
|
||||||
|
if: needs.relevant.outputs.run == 'true'
|
||||||
runs-on: linux
|
runs-on: linux
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
|
|
@ -167,6 +202,8 @@ jobs:
|
||||||
|
|
||||||
media:
|
media:
|
||||||
name: wai-pack · the floor through a media prober
|
name: wai-pack · the floor through a media prober
|
||||||
|
needs: relevant
|
||||||
|
if: needs.relevant.outputs.run == 'true'
|
||||||
runs-on: linux
|
runs-on: linux
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
|
|
@ -198,6 +235,8 @@ jobs:
|
||||||
|
|
||||||
licences:
|
licences:
|
||||||
name: wai-pack · dependency licences
|
name: wai-pack · dependency licences
|
||||||
|
needs: relevant
|
||||||
|
if: needs.relevant.outputs.run == 'true'
|
||||||
runs-on: linux
|
runs-on: linux
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
|
|
|
||||||
21
.github/workflows/workflow-hygiene.yml
vendored
21
.github/workflows/workflow-hygiene.yml
vendored
|
|
@ -2,8 +2,8 @@ name: workflow hygiene
|
||||||
|
|
||||||
# Properties of the workflows themselves, checked rather than remembered.
|
# Properties of the workflows themselves, checked rather than remembered.
|
||||||
#
|
#
|
||||||
# Today that is one property: every workflow declares a concurrency group that
|
Two properties today. The first: every workflow declares a concurrency group
|
||||||
# varies by ref. None of them did until 2026-10-05, and the consequence was not
|
# 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
|
# 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
|
# 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
|
# 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
|
# whole repo, invisibly: the workflow still passes, it just queues forever
|
||||||
# under load.
|
# 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,
|
# 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
|
# which already serves 13 of this repo's 21 contexts through two runners. It
|
||||||
# needs no toolchain and finishes in seconds.
|
# needs no toolchain and finishes in seconds.
|
||||||
|
|
@ -23,6 +32,9 @@ on:
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/**'
|
- '.github/workflows/**'
|
||||||
- 'scripts/check-workflow-concurrency.sh'
|
- 'scripts/check-workflow-concurrency.sh'
|
||||||
|
- 'scripts/check-workflow-concurrency-selftest.sh'
|
||||||
|
- 'scripts/pr-touches-paths.py'
|
||||||
|
- 'scripts/pr-touches-paths-selftest.sh'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
|
@ -45,3 +57,8 @@ jobs:
|
||||||
|
|
||||||
- name: Every workflow is grouped per ref
|
- name: Every workflow is grouped per ref
|
||||||
run: ./scripts/check-workflow-concurrency.sh
|
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
|
||||||
|
|
|
||||||
70
scripts/pr-touches-paths-selftest.sh
Executable file
70
scripts/pr-touches-paths-selftest.sh
Executable 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
104
scripts/pr-touches-paths.py
Executable 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()
|
||||||
Loading…
Reference in a new issue