Skip to content

Commit 2aeefe2

Browse files
Skip the vlt compat matrix on ci.yml-only changes (#1284)
* Skip the vlt matrix on ci.yml-only changes vlt-compatibility's PR and push filters list ci.yml because install-proof leaves out the cells ci.yml's vlt e2e rows already run. Most ci.yml edits don't touch those rows, yet each one reran the whole matrix (about 41 Linux + 38 Windows job-min per PR run, plus 22 macOS on push). In the last 24h that was 10 of the 24 merged PRs that triggered the workflow, and 5 of its 45 push runs. A new `changes` job runs scripts/vlt-compat-gate.py. It skips build, plan and everything after them only when ci.yml is the one changed file the event's filter matches and the vlt cells parsed from ci.yml are the same on base and head. matrix-coverage still runs, and schedule, dispatch or any doubt (missing base, parse error) run the full matrix. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVLGaJFexMvqCdiWxbiHXB * Match odd and renamed paths in the vlt gate `git diff --name-only` split on whitespace dropped paths with spaces, quoted non-ASCII names, and reported only the new side of a rename, so a vlt file changed that way next to an inert ci.yml edit could read as "only ci.yml changed". Read NUL-separated paths with --no-renames and test it against a scratch repository. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVLGaJFexMvqCdiWxbiHXB --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a8e9397 commit 2aeefe2

3 files changed

Lines changed: 306 additions & 4 deletions

File tree

‎.github/workflows/vlt-compatibility.yml‎

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ on:
4343
- 'scripts/vlt-historical-integrity.json'
4444
- 'scripts/gen-vlt-collation-golden.mjs'
4545
- 'scripts/ci-vlt-proof-suites.py'
46+
- 'scripts/vlt-compat-gate.py'
47+
- 'scripts/tests/test_vlt_compat_gate.py'
4648
- '.github/workflows/ci.yml'
4749
- 'scripts/tests/test_ci_vlt_rows.py'
4850
push:
@@ -84,6 +86,8 @@ on:
8486
- 'scripts/vlt-historical-integrity.json'
8587
- 'scripts/gen-vlt-collation-golden.mjs'
8688
- 'scripts/ci-vlt-proof-suites.py'
89+
- 'scripts/vlt-compat-gate.py'
90+
- 'scripts/tests/test_vlt_compat_gate.py'
8791
- '.github/workflows/ci.yml'
8892
- 'scripts/tests/test_ci_vlt_rows.py'
8993
schedule:
@@ -136,8 +140,41 @@ jobs:
136140
- name: Every era, suite and OS is covered
137141
run: python3 -B -m unittest scripts/tests/test_ci_vlt_rows.py -v
138142

139-
build:
143+
# Both filters list ci.yml for its vlt `e2e` rows, which install-proof
144+
# leaves out. A PR or push whose only matching change is ci.yml, with
145+
# those rows untouched, would rerun the matrix exactly as on the base:
146+
# matrix-coverage above still checks it, the rest is skipped.
147+
changes:
140148
if: github.event.pull_request.draft != true
149+
runs-on: ubuntu-latest
150+
timeout-minutes: 5
151+
outputs:
152+
matrix: ${{ steps.gate.outputs.matrix }}
153+
steps:
154+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
155+
with:
156+
persist-credentials: false
157+
# A PR's merge commit has the base it was merged onto as parent 1.
158+
fetch-depth: 2
159+
- id: gate
160+
env:
161+
EVENT_NAME: ${{ github.event_name }}
162+
PUSH_BEFORE: ${{ github.event.before }}
163+
run: |
164+
set -euo pipefail
165+
case "$EVENT_NAME" in
166+
pull_request) base=HEAD^1 ;;
167+
push) base="$PUSH_BEFORE" ;;
168+
*) base='' ;;
169+
esac
170+
if [ -n "$base" ] && ! git rev-parse --verify --quiet "$base^{commit}" >/dev/null; then
171+
git fetch --quiet --depth 1 origin "$base" || true
172+
fi
173+
python3 -B scripts/vlt-compat-gate.py --event "$EVENT_NAME" --base "$base" >> "$GITHUB_OUTPUT"
174+
175+
build:
176+
needs: changes
177+
if: needs.changes.outputs.matrix == 'true'
141178
strategy:
142179
fail-fast: false
143180
matrix:
@@ -373,7 +410,8 @@ jobs:
373410
steps: *install-proof-steps
374411

375412
plan:
376-
if: github.event.pull_request.draft != true
413+
needs: changes
414+
if: needs.changes.outputs.matrix == 'true'
377415
runs-on: ubuntu-latest
378416
timeout-minutes: 5
379417
outputs:
@@ -468,8 +506,8 @@ jobs:
468506
retention-days: 14
469507

470508
lock-diff:
471-
needs: native
472-
if: ${{ !cancelled() }}
509+
needs: [changes, native]
510+
if: ${{ !cancelled() && needs.changes.outputs.matrix == 'true' }}
473511
runs-on: ubuntu-latest
474512
timeout-minutes: 10
475513
steps:
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"""scripts/vlt-compat-gate.py: vlt-compatibility skips its matrix only when
2+
ci.yml is the one matching change and its vlt cells are untouched."""
3+
4+
import contextlib
5+
import importlib.util
6+
import io
7+
import subprocess
8+
import tempfile
9+
import unittest
10+
from pathlib import Path
11+
12+
ROOT = Path(__file__).parents[2]
13+
CI = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")
14+
COMPAT = (ROOT / ".github" / "workflows" / "vlt-compatibility.yml").read_text(encoding="utf-8")
15+
16+
spec = importlib.util.spec_from_file_location("gate", ROOT / "scripts" / "vlt-compat-gate.py")
17+
gate = importlib.util.module_from_spec(spec)
18+
spec.loader.exec_module(gate)
19+
20+
PR = gate.event_paths(COMPAT, "pull_request")
21+
PUSH = gate.event_paths(COMPAT, "push")
22+
CI_PATH = ".github/workflows/ci.yml"
23+
24+
25+
def drop_a_vlt_row(text):
26+
lines = text.splitlines(keepends=True)
27+
for i, line in enumerate(lines):
28+
if "vlt:" in line and "--include-ignored vlt_pinned_matrix" in line:
29+
return "".join(lines[:i] + lines[i + 1:])
30+
raise AssertionError("no vlt row in ci.yml")
31+
32+
33+
class Filters(unittest.TestCase):
34+
def test_both_events_list_ci_yml_and_the_gate(self):
35+
for paths in (PR, PUSH):
36+
self.assertIn(CI_PATH, paths)
37+
self.assertIn("scripts/vlt-compat-gate.py", paths)
38+
self.assertIn("crates/socket-patch-core/src/vendor/**", PUSH)
39+
self.assertNotIn("crates/socket-patch-core/src/vendor/**", PR)
40+
41+
def test_globs(self):
42+
star = gate.glob_re("crates/*/src/**/*vlt*")
43+
self.assertTrue(star.match("crates/socket-patch-core/src/vendor/vlt.rs"))
44+
self.assertTrue(star.match("crates/socket-patch-cli/src/vlt_preflight.rs"))
45+
self.assertFalse(star.match("crates/a/b/src/vlt.rs"))
46+
self.assertTrue(gate.glob_re("crates/x/**").match("crates/x/a/b.rs"))
47+
self.assertFalse(gate.glob_re("crates/x/*.rs").match("crates/x/a/b.rs"))
48+
49+
50+
class Decision(unittest.TestCase):
51+
def test_ci_yml_alone_with_the_same_cells_skips(self):
52+
edited = CI + "\n# an unrelated edit\n"
53+
for event, paths in (("pull_request", PR), ("push", PUSH)):
54+
self.assertFalse(gate.needs_matrix(event, [CI_PATH, "README.md"], paths, CI, edited))
55+
56+
def test_a_changed_vlt_row_runs(self):
57+
self.assertTrue(gate.needs_matrix("pull_request", [CI_PATH], PR, CI, drop_a_vlt_row(CI)))
58+
59+
def test_another_matching_file_runs(self):
60+
changed = [CI_PATH, "scripts/check-vlt-legs.py"]
61+
self.assertTrue(gate.needs_matrix("pull_request", changed, PR, CI, CI))
62+
changed = [CI_PATH, "crates/socket-patch-core/src/vendor/npm.rs"]
63+
self.assertTrue(gate.needs_matrix("push", changed, PUSH, CI, CI))
64+
65+
def test_other_events_and_missing_inputs_run(self):
66+
for event in ("schedule", "workflow_dispatch"):
67+
self.assertTrue(gate.needs_matrix(event, [CI_PATH], PR, CI, CI))
68+
self.assertTrue(gate.needs_matrix("pull_request", [CI_PATH], [], CI, CI))
69+
self.assertTrue(gate.needs_matrix("pull_request", [CI_PATH], PR, None, CI))
70+
71+
def test_no_base_runs(self):
72+
out = io.StringIO()
73+
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(io.StringIO()):
74+
gate.main(["--event", "pull_request", "--base", "0" * 40])
75+
self.assertEqual(out.getvalue().split(), ["matrix=true"])
76+
77+
78+
class ChangedPaths(unittest.TestCase):
79+
"""main() on real commits: odd file names and renames still match."""
80+
81+
def commit_pair(self, before, after):
82+
tmp = tempfile.TemporaryDirectory()
83+
self.addCleanup(tmp.cleanup)
84+
repo = Path(tmp.name)
85+
86+
def run(*args):
87+
return subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True,
88+
text=True).stdout.strip()
89+
90+
def write(files):
91+
for name, body in files.items():
92+
path = repo / name
93+
path.parent.mkdir(parents=True, exist_ok=True)
94+
path.write_text(body, encoding="utf-8")
95+
96+
run("init", "-q")
97+
run("config", "user.email", "t@example.com")
98+
run("config", "user.name", "t")
99+
write(before)
100+
run("add", "-A")
101+
run("commit", "-qm", "base")
102+
base = run("rev-parse", "HEAD")
103+
for name in [n for n in before if n not in after]:
104+
run("rm", "-q", name)
105+
write(after)
106+
run("add", "-A")
107+
run("commit", "-qm", "head")
108+
return repo, base
109+
110+
def decide(self, before, after):
111+
repo, base = self.commit_pair(before, after)
112+
out = io.StringIO()
113+
old_repo = gate.REPO
114+
gate.REPO = repo
115+
try:
116+
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(io.StringIO()):
117+
gate.main(["--event", "pull_request", "--base", base])
118+
finally:
119+
gate.REPO = old_repo
120+
return out.getvalue().split()
121+
122+
def base_tree(self):
123+
return {CI_PATH: CI, "scripts/check-vlt-legs.py": "x\n"}
124+
125+
def test_inert_ci_yml_edit_skips(self):
126+
after = dict(self.base_tree(), **{CI_PATH: CI + "\n# unrelated\n"})
127+
self.assertEqual(self.decide(self.base_tree(), after), ["matrix=false"])
128+
129+
def test_odd_names_and_renames_still_run(self):
130+
for name in ("crates/socket-patch-cli/tests/a vlt b.rs",
131+
"crates/socket-patch-cli/tests/\u00e9vlt.rs"):
132+
after = dict(self.base_tree(), **{CI_PATH: CI + "\n# unrelated\n", name: "x\n"})
133+
self.assertEqual(self.decide(self.base_tree(), after), ["matrix=true"], name)
134+
moved = {CI_PATH: CI + "\n# unrelated\n", "scripts/elsewhere.py": "x\n"}
135+
self.assertEqual(self.decide(self.base_tree(), moved), ["matrix=true"])
136+
137+
138+
class Workflow(unittest.TestCase):
139+
def test_heavy_jobs_wait_on_the_gate(self):
140+
for job in ("build", "plan"):
141+
block = COMPAT.split(f"\n {job}:\n", 1)[1].split("\n ", 1)[0]
142+
self.assertIn("needs: changes", block, job)
143+
self.assertIn("needs.changes.outputs.matrix == 'true'", COMPAT.split("\n lock-diff:\n", 1)[1][:200])
144+
self.assertIn("scripts/vlt-compat-gate.py", COMPAT.split("\n changes:\n", 1)[1])
145+
146+
147+
if __name__ == "__main__":
148+
unittest.main()

‎scripts/vlt-compat-gate.py‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
#!/usr/bin/env python3
2+
"""Print whether a vlt-compatibility run needs its matrix (`matrix=true`).
3+
4+
The workflow's pull_request and push filters list ci.yml because
5+
install-proof leaves out the cells ci.yml's `e2e` rows already run
6+
(scripts/ci-vlt-proof-suites.py). Most ci.yml edits don't touch those rows,
7+
and then the matrix would run exactly as it did on the base. So the answer
8+
is `matrix=false` only when ci.yml is the one changed file the event's
9+
filter matches and its vlt cells are the same on both sides. Anything
10+
unexpected (a base that isn't there, a filter that doesn't parse) answers
11+
`matrix=true`.
12+
"""
13+
14+
import argparse
15+
import importlib.util
16+
import re
17+
import subprocess
18+
import sys
19+
from pathlib import Path
20+
21+
ROOT = Path(__file__).resolve().parents[1]
22+
WORKFLOW = ROOT / ".github" / "workflows" / "vlt-compatibility.yml"
23+
REPO = ROOT # where git runs; the tests point it at a scratch repository
24+
CI_PATH = ".github/workflows/ci.yml"
25+
26+
27+
def event_paths(text, event):
28+
"""The `paths:` list of `on.<event>` in the workflow text."""
29+
paths, in_on, in_event, in_paths = [], False, False, False
30+
for line in text.splitlines():
31+
if not line.strip() or line.lstrip().startswith("#"):
32+
continue
33+
depth = len(line) - len(line.lstrip(" "))
34+
if depth == 0:
35+
in_on = line.rstrip() == "on:"
36+
in_event = in_paths = False
37+
elif in_on and depth == 2:
38+
in_event = line.strip() == f"{event}:"
39+
in_paths = False
40+
elif in_event and depth == 4:
41+
in_paths = line.strip() == "paths:"
42+
elif in_paths and line.strip().startswith("- "):
43+
paths.append(line.strip()[2:].strip().strip("'\""))
44+
return paths
45+
46+
47+
def glob_re(pattern):
48+
"""GitHub's filter globs: `**` crosses `/`, `*` and `?` don't."""
49+
out, i = "", 0
50+
while i < len(pattern):
51+
if pattern.startswith("**/", i):
52+
out, i = out + "(?:.*/)?", i + 3
53+
elif pattern.startswith("**", i):
54+
out, i = out + ".*", i + 2
55+
elif pattern[i] == "*":
56+
out, i = out + "[^/]*", i + 1
57+
elif pattern[i] == "?":
58+
out, i = out + "[^/]", i + 1
59+
else:
60+
out, i = out + re.escape(pattern[i]), i + 1
61+
return re.compile(out + r"\Z")
62+
63+
64+
def ci_cells(text):
65+
path = ROOT / "scripts" / "ci-vlt-proof-suites.py"
66+
spec = importlib.util.spec_from_file_location("ci_vlt_proof_suites", path)
67+
module = importlib.util.module_from_spec(spec)
68+
spec.loader.exec_module(module)
69+
return module.ci_cells(text)
70+
71+
72+
def git(*args):
73+
return subprocess.run(["git", *args], cwd=REPO, capture_output=True, text=True, check=True).stdout
74+
75+
76+
def needs_matrix(event, changed, filters, base_ci, head_ci):
77+
"""The decision on already-fetched inputs (see the module docstring)."""
78+
if event not in ("pull_request", "push") or not filters:
79+
return True
80+
rules = [glob_re(p) for p in filters]
81+
relevant = [f for f in changed if any(r.match(f) for r in rules)]
82+
if relevant != [CI_PATH]:
83+
return True
84+
return base_ci is None or ci_cells(base_ci) != ci_cells(head_ci)
85+
86+
87+
def main(argv=None):
88+
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
89+
ap.add_argument("--event", required=True)
90+
ap.add_argument("--base", default="")
91+
ap.add_argument("--head", default="HEAD")
92+
args = ap.parse_args(argv)
93+
matrix = True
94+
if args.event in ("pull_request", "push") and args.base:
95+
try:
96+
# NUL-separated paths are never quoted or split on spaces; with
97+
# --no-renames a moved file lists both its old and new path.
98+
out = git("diff", "-z", "--name-only", "--no-renames", args.base, args.head)
99+
changed = [p for p in out.split("\0") if p]
100+
filters = event_paths(WORKFLOW.read_text(encoding="utf-8"), args.event)
101+
base_ci = git("show", f"{args.base}:{CI_PATH}")
102+
head_ci = git("show", f"{args.head}:{CI_PATH}")
103+
matrix = needs_matrix(args.event, changed, filters, base_ci, head_ci)
104+
except Exception as e: # any doubt runs the matrix
105+
detail = getattr(e, "stderr", "") or e
106+
print(f"::warning::vlt-compat-gate: {str(detail).strip()}; running the matrix", file=sys.stderr)
107+
matrix = True
108+
if not matrix:
109+
print("::notice::only ci.yml changed and its vlt cells did not; skipping the vlt matrix",
110+
file=sys.stderr)
111+
print(f"matrix={'true' if matrix else 'false'}")
112+
return 0
113+
114+
115+
if __name__ == "__main__":
116+
sys.exit(main())

0 commit comments

Comments
 (0)