From 9b8fe125b39c895cdf59b25ea39dde5b99d1dc71 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 14:36:32 +0000 Subject: [PATCH 1/2] 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) Claude-Session: https://claude.ai/code/session_01YVLGaJFexMvqCdiWxbiHXB --- .github/workflows/vlt-compatibility.yml | 46 +++++++++- scripts/tests/test_vlt_compat_gate.py | 86 ++++++++++++++++++ scripts/vlt-compat-gate.py | 112 ++++++++++++++++++++++++ 3 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 scripts/tests/test_vlt_compat_gate.py create mode 100644 scripts/vlt-compat-gate.py diff --git a/.github/workflows/vlt-compatibility.yml b/.github/workflows/vlt-compatibility.yml index 466404c65..fcf9aa5ec 100644 --- a/.github/workflows/vlt-compatibility.yml +++ b/.github/workflows/vlt-compatibility.yml @@ -43,6 +43,8 @@ on: - 'scripts/vlt-historical-integrity.json' - 'scripts/gen-vlt-collation-golden.mjs' - 'scripts/ci-vlt-proof-suites.py' + - 'scripts/vlt-compat-gate.py' + - 'scripts/tests/test_vlt_compat_gate.py' - '.github/workflows/ci.yml' - 'scripts/tests/test_ci_vlt_rows.py' push: @@ -84,6 +86,8 @@ on: - 'scripts/vlt-historical-integrity.json' - 'scripts/gen-vlt-collation-golden.mjs' - 'scripts/ci-vlt-proof-suites.py' + - 'scripts/vlt-compat-gate.py' + - 'scripts/tests/test_vlt_compat_gate.py' - '.github/workflows/ci.yml' - 'scripts/tests/test_ci_vlt_rows.py' schedule: @@ -136,8 +140,41 @@ jobs: - name: Every era, suite and OS is covered run: python3 -B -m unittest scripts/tests/test_ci_vlt_rows.py -v - build: + # Both filters list ci.yml for its vlt `e2e` rows, which install-proof + # leaves out. A PR or push whose only matching change is ci.yml, with + # those rows untouched, would rerun the matrix exactly as on the base: + # matrix-coverage above still checks it, the rest is skipped. + changes: if: github.event.pull_request.draft != true + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + matrix: ${{ steps.gate.outputs.matrix }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + # A PR's merge commit has the base it was merged onto as parent 1. + fetch-depth: 2 + - id: gate + env: + EVENT_NAME: ${{ github.event_name }} + PUSH_BEFORE: ${{ github.event.before }} + run: | + set -euo pipefail + case "$EVENT_NAME" in + pull_request) base=HEAD^1 ;; + push) base="$PUSH_BEFORE" ;; + *) base='' ;; + esac + if [ -n "$base" ] && ! git rev-parse --verify --quiet "$base^{commit}" >/dev/null; then + git fetch --quiet --depth 1 origin "$base" || true + fi + python3 -B scripts/vlt-compat-gate.py --event "$EVENT_NAME" --base "$base" >> "$GITHUB_OUTPUT" + + build: + needs: changes + if: needs.changes.outputs.matrix == 'true' strategy: fail-fast: false matrix: @@ -373,7 +410,8 @@ jobs: steps: *install-proof-steps plan: - if: github.event.pull_request.draft != true + needs: changes + if: needs.changes.outputs.matrix == 'true' runs-on: ubuntu-latest timeout-minutes: 5 outputs: @@ -468,8 +506,8 @@ jobs: retention-days: 14 lock-diff: - needs: native - if: ${{ !cancelled() }} + needs: [changes, native] + if: ${{ !cancelled() && needs.changes.outputs.matrix == 'true' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: diff --git a/scripts/tests/test_vlt_compat_gate.py b/scripts/tests/test_vlt_compat_gate.py new file mode 100644 index 000000000..380e51ab1 --- /dev/null +++ b/scripts/tests/test_vlt_compat_gate.py @@ -0,0 +1,86 @@ +"""scripts/vlt-compat-gate.py: vlt-compatibility skips its matrix only when +ci.yml is the one matching change and its vlt cells are untouched.""" + +import contextlib +import importlib.util +import io +import unittest +from pathlib import Path + +ROOT = Path(__file__).parents[2] +CI = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") +COMPAT = (ROOT / ".github" / "workflows" / "vlt-compatibility.yml").read_text(encoding="utf-8") + +spec = importlib.util.spec_from_file_location("gate", ROOT / "scripts" / "vlt-compat-gate.py") +gate = importlib.util.module_from_spec(spec) +spec.loader.exec_module(gate) + +PR = gate.event_paths(COMPAT, "pull_request") +PUSH = gate.event_paths(COMPAT, "push") +CI_PATH = ".github/workflows/ci.yml" + + +def drop_a_vlt_row(text): + lines = text.splitlines(keepends=True) + for i, line in enumerate(lines): + if "vlt:" in line and "--include-ignored vlt_pinned_matrix" in line: + return "".join(lines[:i] + lines[i + 1:]) + raise AssertionError("no vlt row in ci.yml") + + +class Filters(unittest.TestCase): + def test_both_events_list_ci_yml_and_the_gate(self): + for paths in (PR, PUSH): + self.assertIn(CI_PATH, paths) + self.assertIn("scripts/vlt-compat-gate.py", paths) + self.assertIn("crates/socket-patch-core/src/vendor/**", PUSH) + self.assertNotIn("crates/socket-patch-core/src/vendor/**", PR) + + def test_globs(self): + star = gate.glob_re("crates/*/src/**/*vlt*") + self.assertTrue(star.match("crates/socket-patch-core/src/vendor/vlt.rs")) + self.assertTrue(star.match("crates/socket-patch-cli/src/vlt_preflight.rs")) + self.assertFalse(star.match("crates/a/b/src/vlt.rs")) + self.assertTrue(gate.glob_re("crates/x/**").match("crates/x/a/b.rs")) + self.assertFalse(gate.glob_re("crates/x/*.rs").match("crates/x/a/b.rs")) + + +class Decision(unittest.TestCase): + def test_ci_yml_alone_with_the_same_cells_skips(self): + edited = CI + "\n# an unrelated edit\n" + for event, paths in (("pull_request", PR), ("push", PUSH)): + self.assertFalse(gate.needs_matrix(event, [CI_PATH, "README.md"], paths, CI, edited)) + + def test_a_changed_vlt_row_runs(self): + self.assertTrue(gate.needs_matrix("pull_request", [CI_PATH], PR, CI, drop_a_vlt_row(CI))) + + def test_another_matching_file_runs(self): + changed = [CI_PATH, "scripts/check-vlt-legs.py"] + self.assertTrue(gate.needs_matrix("pull_request", changed, PR, CI, CI)) + changed = [CI_PATH, "crates/socket-patch-core/src/vendor/npm.rs"] + self.assertTrue(gate.needs_matrix("push", changed, PUSH, CI, CI)) + + def test_other_events_and_missing_inputs_run(self): + for event in ("schedule", "workflow_dispatch"): + self.assertTrue(gate.needs_matrix(event, [CI_PATH], PR, CI, CI)) + self.assertTrue(gate.needs_matrix("pull_request", [CI_PATH], [], CI, CI)) + self.assertTrue(gate.needs_matrix("pull_request", [CI_PATH], PR, None, CI)) + + def test_no_base_runs(self): + out = io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(io.StringIO()): + gate.main(["--event", "pull_request", "--base", "0" * 40]) + self.assertEqual(out.getvalue().split(), ["matrix=true"]) + + +class Workflow(unittest.TestCase): + def test_heavy_jobs_wait_on_the_gate(self): + for job in ("build", "plan"): + block = COMPAT.split(f"\n {job}:\n", 1)[1].split("\n ", 1)[0] + self.assertIn("needs: changes", block, job) + self.assertIn("needs.changes.outputs.matrix == 'true'", COMPAT.split("\n lock-diff:\n", 1)[1][:200]) + self.assertIn("scripts/vlt-compat-gate.py", COMPAT.split("\n changes:\n", 1)[1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/vlt-compat-gate.py b/scripts/vlt-compat-gate.py new file mode 100644 index 000000000..ddea58e9d --- /dev/null +++ b/scripts/vlt-compat-gate.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Print whether a vlt-compatibility run needs its matrix (`matrix=true`). + +The workflow's pull_request and push filters list ci.yml because +install-proof leaves out the cells ci.yml's `e2e` rows already run +(scripts/ci-vlt-proof-suites.py). Most ci.yml edits don't touch those rows, +and then the matrix would run exactly as it did on the base. So the answer +is `matrix=false` only when ci.yml is the one changed file the event's +filter matches and its vlt cells are the same on both sides. Anything +unexpected (a base that isn't there, a filter that doesn't parse) answers +`matrix=true`. +""" + +import argparse +import importlib.util +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github" / "workflows" / "vlt-compatibility.yml" +CI_PATH = ".github/workflows/ci.yml" + + +def event_paths(text, event): + """The `paths:` list of `on.` in the workflow text.""" + paths, in_on, in_event, in_paths = [], False, False, False + for line in text.splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + depth = len(line) - len(line.lstrip(" ")) + if depth == 0: + in_on = line.rstrip() == "on:" + in_event = in_paths = False + elif in_on and depth == 2: + in_event = line.strip() == f"{event}:" + in_paths = False + elif in_event and depth == 4: + in_paths = line.strip() == "paths:" + elif in_paths and line.strip().startswith("- "): + paths.append(line.strip()[2:].strip().strip("'\"")) + return paths + + +def glob_re(pattern): + """GitHub's filter globs: `**` crosses `/`, `*` and `?` don't.""" + out, i = "", 0 + while i < len(pattern): + if pattern.startswith("**/", i): + out, i = out + "(?:.*/)?", i + 3 + elif pattern.startswith("**", i): + out, i = out + ".*", i + 2 + elif pattern[i] == "*": + out, i = out + "[^/]*", i + 1 + elif pattern[i] == "?": + out, i = out + "[^/]", i + 1 + else: + out, i = out + re.escape(pattern[i]), i + 1 + return re.compile(out + r"\Z") + + +def ci_cells(text): + path = ROOT / "scripts" / "ci-vlt-proof-suites.py" + spec = importlib.util.spec_from_file_location("ci_vlt_proof_suites", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.ci_cells(text) + + +def git(*args): + return subprocess.run(["git", *args], cwd=ROOT, capture_output=True, text=True, check=True).stdout + + +def needs_matrix(event, changed, filters, base_ci, head_ci): + """The decision on already-fetched inputs (see the module docstring).""" + if event not in ("pull_request", "push") or not filters: + return True + rules = [glob_re(p) for p in filters] + relevant = [f for f in changed if any(r.match(f) for r in rules)] + if relevant != [CI_PATH]: + return True + return base_ci is None or ci_cells(base_ci) != ci_cells(head_ci) + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--event", required=True) + ap.add_argument("--base", default="") + ap.add_argument("--head", default="HEAD") + args = ap.parse_args(argv) + matrix = True + if args.event in ("pull_request", "push") and args.base: + try: + changed = git("diff", "--name-only", args.base, args.head).split() + filters = event_paths(WORKFLOW.read_text(encoding="utf-8"), args.event) + base_ci = git("show", f"{args.base}:{CI_PATH}") + head_ci = git("show", f"{args.head}:{CI_PATH}") + matrix = needs_matrix(args.event, changed, filters, base_ci, head_ci) + except Exception as e: # any doubt runs the matrix + detail = getattr(e, "stderr", "") or e + print(f"::warning::vlt-compat-gate: {str(detail).strip()}; running the matrix", file=sys.stderr) + matrix = True + if not matrix: + print("::notice::only ci.yml changed and its vlt cells did not; skipping the vlt matrix", + file=sys.stderr) + print(f"matrix={'true' if matrix else 'false'}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 2be0fcd3b1b1c9ffef728fe365834f0d0c58d1a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 14:51:20 +0000 Subject: [PATCH 2/2] 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) Claude-Session: https://claude.ai/code/session_01YVLGaJFexMvqCdiWxbiHXB --- scripts/tests/test_vlt_compat_gate.py | 62 +++++++++++++++++++++++++++ scripts/vlt-compat-gate.py | 8 +++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/scripts/tests/test_vlt_compat_gate.py b/scripts/tests/test_vlt_compat_gate.py index 380e51ab1..34a9ba0ce 100644 --- a/scripts/tests/test_vlt_compat_gate.py +++ b/scripts/tests/test_vlt_compat_gate.py @@ -4,6 +4,8 @@ import contextlib import importlib.util import io +import subprocess +import tempfile import unittest from pathlib import Path @@ -73,6 +75,66 @@ def test_no_base_runs(self): self.assertEqual(out.getvalue().split(), ["matrix=true"]) +class ChangedPaths(unittest.TestCase): + """main() on real commits: odd file names and renames still match.""" + + def commit_pair(self, before, after): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + repo = Path(tmp.name) + + def run(*args): + return subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True, + text=True).stdout.strip() + + def write(files): + for name, body in files.items(): + path = repo / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + + run("init", "-q") + run("config", "user.email", "t@example.com") + run("config", "user.name", "t") + write(before) + run("add", "-A") + run("commit", "-qm", "base") + base = run("rev-parse", "HEAD") + for name in [n for n in before if n not in after]: + run("rm", "-q", name) + write(after) + run("add", "-A") + run("commit", "-qm", "head") + return repo, base + + def decide(self, before, after): + repo, base = self.commit_pair(before, after) + out = io.StringIO() + old_repo = gate.REPO + gate.REPO = repo + try: + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(io.StringIO()): + gate.main(["--event", "pull_request", "--base", base]) + finally: + gate.REPO = old_repo + return out.getvalue().split() + + def base_tree(self): + return {CI_PATH: CI, "scripts/check-vlt-legs.py": "x\n"} + + def test_inert_ci_yml_edit_skips(self): + after = dict(self.base_tree(), **{CI_PATH: CI + "\n# unrelated\n"}) + self.assertEqual(self.decide(self.base_tree(), after), ["matrix=false"]) + + def test_odd_names_and_renames_still_run(self): + for name in ("crates/socket-patch-cli/tests/a vlt b.rs", + "crates/socket-patch-cli/tests/\u00e9vlt.rs"): + after = dict(self.base_tree(), **{CI_PATH: CI + "\n# unrelated\n", name: "x\n"}) + self.assertEqual(self.decide(self.base_tree(), after), ["matrix=true"], name) + moved = {CI_PATH: CI + "\n# unrelated\n", "scripts/elsewhere.py": "x\n"} + self.assertEqual(self.decide(self.base_tree(), moved), ["matrix=true"]) + + class Workflow(unittest.TestCase): def test_heavy_jobs_wait_on_the_gate(self): for job in ("build", "plan"): diff --git a/scripts/vlt-compat-gate.py b/scripts/vlt-compat-gate.py index ddea58e9d..8d1b31535 100644 --- a/scripts/vlt-compat-gate.py +++ b/scripts/vlt-compat-gate.py @@ -20,6 +20,7 @@ ROOT = Path(__file__).resolve().parents[1] WORKFLOW = ROOT / ".github" / "workflows" / "vlt-compatibility.yml" +REPO = ROOT # where git runs; the tests point it at a scratch repository CI_PATH = ".github/workflows/ci.yml" @@ -69,7 +70,7 @@ def ci_cells(text): def git(*args): - return subprocess.run(["git", *args], cwd=ROOT, capture_output=True, text=True, check=True).stdout + return subprocess.run(["git", *args], cwd=REPO, capture_output=True, text=True, check=True).stdout def needs_matrix(event, changed, filters, base_ci, head_ci): @@ -92,7 +93,10 @@ def main(argv=None): matrix = True if args.event in ("pull_request", "push") and args.base: try: - changed = git("diff", "--name-only", args.base, args.head).split() + # NUL-separated paths are never quoted or split on spaces; with + # --no-renames a moved file lists both its old and new path. + out = git("diff", "-z", "--name-only", "--no-renames", args.base, args.head) + changed = [p for p in out.split("\0") if p] filters = event_paths(WORKFLOW.read_text(encoding="utf-8"), args.event) base_ci = git("show", f"{args.base}:{CI_PATH}") head_ci = git("show", f"{args.head}:{CI_PATH}")