Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 42 additions & 4 deletions .github/workflows/vlt-compatibility.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
148 changes: 148 additions & 0 deletions scripts/tests/test_vlt_compat_gate.py
Original file line number Diff line number Diff line change
@@ -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()
116 changes: 116 additions & 0 deletions scripts/vlt-compat-gate.py
Original file line number Diff line number Diff line change
@@ -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.<event>` 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())
Loading