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..34a9ba0ce --- /dev/null +++ b/scripts/tests/test_vlt_compat_gate.py @@ -0,0 +1,148 @@ +"""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 subprocess +import tempfile +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 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"): + 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..8d1b31535 --- /dev/null +++ b/scripts/vlt-compat-gate.py @@ -0,0 +1,116 @@ +#!/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" +REPO = ROOT # where git runs; the tests point it at a scratch repository +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=REPO, 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: + # 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}") + 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())