[ci] Invoke arm64 macOS tests if hal/drivers/metal changes (#14700)

This commit changes CI rules to enable running arm64
macOS builds and tests on presubmit if we see changes
to `hal/drivers/metal` directory. This can guard against
breaking changes.
diff --git a/build_tools/github_actions/configure_ci.py b/build_tools/github_actions/configure_ci.py
index f55081c..7b3861b 100755
--- a/build_tools/github_actions/configure_ci.py
+++ b/build_tools/github_actions/configure_ci.py
@@ -45,7 +45,7 @@
 import subprocess
 import sys
 import textwrap
-from typing import Iterable, List, Mapping, Sequence, Set, Tuple
+from typing import Iterable, List, Mapping, Optional, Sequence, Set, Tuple
 
 import yaml
 
@@ -115,7 +115,9 @@
 
 CONTROL_JOBS = frozenset(["setup", "summary"])
 
-POSTSUBMIT_ONLY_JOBS = frozenset(
+# Jobs to run only on postsubmit by default.
+# They may also run on presubmit only under certain conditions.
+DEFAULT_POSTSUBMIT_ONLY_JOBS = frozenset(
     [
         "build_test_all_windows",
         "build_test_all_macos_arm64",
@@ -125,6 +127,13 @@
     ]
 )
 
+# Jobs to run in presumbit if files under the corresponding path see changes.
+# Each tuple consists of the CI job name and a list of file paths to match.
+# The file paths should be specified using Unix shell-style wildcards.
+PRESUBMIT_TOUCH_ONLY_JOBS = [
+    ("build_test_all_macos_arm64", ["runtime/src/iree/hal/drivers/metal/*"]),
+]
+
 DEFAULT_BENCHMARK_PRESET_GROUP = [
     "cuda",
     "x86_64",
@@ -291,27 +300,30 @@
     return (trailer_map, labels)
 
 
-def get_modified_paths(base_ref: str) -> Iterable[str]:
-    return subprocess.run(
-        ["git", "diff", "--name-only", base_ref],
-        stdout=subprocess.PIPE,
-        check=True,
-        text=True,
-        timeout=60,
-    ).stdout.splitlines()
-
-
-def modifies_included_path() -> bool:
-    base_ref = os.environ["BASE_REF"]
+def get_modified_paths(base_ref: str) -> Optional[Iterable[str]]:
+    """Returns the paths of modified files in this code change."""
     try:
-        return any(not skip_path(p) for p in get_modified_paths(base_ref))
+        return subprocess.run(
+            ["git", "diff", "--name-only", base_ref],
+            stdout=subprocess.PIPE,
+            check=True,
+            text=True,
+            timeout=60,
+        ).stdout.splitlines()
     except TimeoutError as e:
         print(
             "Computing modified files timed out. Not using PR diff to determine"
             " jobs to run.",
             file=sys.stderr,
         )
+        return None
+
+
+def modifies_non_skip_paths(paths: Optional[Iterable[str]]) -> bool:
+    """Returns true if not all modified paths are in the skip set."""
+    if paths is None:
         return True
+    return any(not skip_path(p) for p in paths)
 
 
 def get_runner_env(trailers: Mapping[str, str]) -> str:
@@ -381,8 +393,20 @@
     all_jobs: Set[str],
     *,
     is_pr: bool,
-    modifies: bool,
+    modified_paths: Optional[Iterable[str]],
 ) -> Set[str]:
+    """Returns the CI jobs to run.
+
+    Args:
+      trailers: trailers from PR description.
+      all_jobs: all known supported jobs.
+      is_pr: whether this is for pull requests or not.
+      modified_paths: the paths of the files changed. These paths are
+        relative to the repo root directory.
+
+    Returns:
+      The list of CI jobs to run.
+    """
     if not is_pr:
         print(
             "Running all jobs because run was not triggered by a pull request"
@@ -432,14 +456,21 @@
             f" '{Trailer.EXTRA_JOBS}', but found {ambiguous_jobs}"
         )
 
-    default_jobs = all_jobs - POSTSUBMIT_ONLY_JOBS
+    default_jobs = all_jobs - DEFAULT_POSTSUBMIT_ONLY_JOBS
 
-    if not modifies:
+    if not modifies_non_skip_paths(modified_paths):
         print(
             "Not including any jobs by default because all modified files"
             " are marked as excluded."
         )
         default_jobs = frozenset()
+    else:
+        # Add jobs if the monitored files are changed.
+        for modified_path in modified_paths:
+            for job, match_paths in PRESUBMIT_TOUCH_ONLY_JOBS:
+                for match_path in match_paths:
+                    if fnmatch.fnmatch(modified_path, match_path):
+                        default_jobs |= {job}
 
     return (default_jobs | extra_jobs) - skip_jobs
 
@@ -525,8 +556,8 @@
     repo = os.environ["GITHUB_REPOSITORY"]
     workflow_ref = os.environ["GITHUB_WORKFLOW_REF"]
     workflow_file = parse_path_from_workflow_ref(repo=repo, workflow_ref=workflow_ref)
+    base_ref = os.environ["BASE_REF"]
 
-    modifies = modifies_included_path()
     try:
         benchmark_presets = get_benchmark_presets(
             trailers, labels, is_pr, is_llvm_integrate_pr
@@ -535,7 +566,7 @@
         enabled_jobs = get_enabled_jobs(
             trailers,
             all_jobs,
-            modifies=modifies,
+            modified_paths=get_modified_paths(base_ref),
             is_pr=is_pr,
         )
     except ValueError as e:
diff --git a/build_tools/github_actions/configure_ci_test.py b/build_tools/github_actions/configure_ci_test.py
index 66282c7..9b3aada 100644
--- a/build_tools/github_actions/configure_ci_test.py
+++ b/build_tools/github_actions/configure_ci_test.py
@@ -147,11 +147,11 @@
         trailers = {}
         all_jobs = {"job1", "job2", "job3"}
         is_pr = True
-        modifies = True
+        modified_paths = ["runtime/file"]
         jobs = configure_ci.get_enabled_jobs(
             trailers,
             all_jobs,
-            modifies=modifies,
+            modified_paths=modified_paths,
             is_pr=is_pr,
         )
         self.assertCountEqual(jobs, all_jobs)
@@ -159,14 +159,14 @@
     def test_get_enabled_jobs_postsubmit(self):
         trailers = {}
         default_jobs = {"job1", "job2", "job3"}
-        postsubmit_job = next(iter(configure_ci.POSTSUBMIT_ONLY_JOBS))
+        postsubmit_job = next(iter(configure_ci.DEFAULT_POSTSUBMIT_ONLY_JOBS))
         all_jobs = default_jobs | {postsubmit_job}
         is_pr = False
-        modifies = True
+        modified_paths = ["runtime/file"]
         jobs = configure_ci.get_enabled_jobs(
             trailers,
             all_jobs,
-            modifies=modifies,
+            modified_paths=modified_paths,
             is_pr=is_pr,
         )
         self.assertCountEqual(jobs, all_jobs)
@@ -174,14 +174,14 @@
     def test_get_enabled_jobs_no_postsubmit(self):
         trailers = {}
         default_jobs = {"job1", "job2", "job3"}
-        postsubmit_job = next(iter(configure_ci.POSTSUBMIT_ONLY_JOBS))
+        postsubmit_job = next(iter(configure_ci.DEFAULT_POSTSUBMIT_ONLY_JOBS))
         all_jobs = default_jobs | {postsubmit_job}
         is_pr = True
-        modifies = True
+        modified_paths = ["runtime/file"]
         jobs = configure_ci.get_enabled_jobs(
             trailers,
             all_jobs,
-            modifies=modifies,
+            modified_paths=modified_paths,
             is_pr=is_pr,
         )
         self.assertCountEqual(jobs, default_jobs)
@@ -189,14 +189,14 @@
     def test_get_enabled_jobs_no_modifies(self):
         trailers = {}
         default_jobs = {"job1", "job2", "job3"}
-        postsubmit_job = next(iter(configure_ci.POSTSUBMIT_ONLY_JOBS))
+        postsubmit_job = next(iter(configure_ci.DEFAULT_POSTSUBMIT_ONLY_JOBS))
         all_jobs = default_jobs | {postsubmit_job}
         is_pr = True
-        modifies = False
+        modified_paths = ["experimental/file"]
         jobs = configure_ci.get_enabled_jobs(
             trailers,
             all_jobs,
-            modifies=modifies,
+            modified_paths=modified_paths,
             is_pr=is_pr,
         )
         self.assertCountEqual(jobs, {})
@@ -204,14 +204,14 @@
     def test_get_enabled_jobs_skip(self):
         trailers = {configure_ci.Trailer.SKIP_JOBS: "job1,job2"}
         default_jobs = {"job1", "job2", "job3"}
-        postsubmit_job = next(iter(configure_ci.POSTSUBMIT_ONLY_JOBS))
+        postsubmit_job = next(iter(configure_ci.DEFAULT_POSTSUBMIT_ONLY_JOBS))
         all_jobs = default_jobs | {postsubmit_job}
         is_pr = True
-        modifies = True
+        modified_paths = ["runtime/file"]
         jobs = configure_ci.get_enabled_jobs(
             trailers,
             all_jobs,
-            modifies=modifies,
+            modified_paths=modified_paths,
             is_pr=is_pr,
         )
         self.assertCountEqual(jobs, {"job3"})
@@ -219,48 +219,62 @@
     def test_get_enabled_jobs_skip_all(self):
         trailers = {configure_ci.Trailer.SKIP_JOBS: "all"}
         default_jobs = {"job1", "job2", "job3"}
-        postsubmit_job = next(iter(configure_ci.POSTSUBMIT_ONLY_JOBS))
+        postsubmit_job = next(iter(configure_ci.DEFAULT_POSTSUBMIT_ONLY_JOBS))
         all_jobs = default_jobs | {postsubmit_job}
         is_pr = True
-        modifies = True
+        modified_paths = ["runtime/file"]
         jobs = configure_ci.get_enabled_jobs(
             trailers,
             all_jobs,
-            modifies=modifies,
+            modified_paths=modified_paths,
             is_pr=is_pr,
         )
         self.assertCountEqual(jobs, {})
 
     def test_get_enabled_jobs_extra(self):
-        postsubmit_job = next(iter(configure_ci.POSTSUBMIT_ONLY_JOBS))
+        postsubmit_job = next(iter(configure_ci.DEFAULT_POSTSUBMIT_ONLY_JOBS))
         trailers = {configure_ci.Trailer.EXTRA_JOBS: postsubmit_job}
         default_jobs = {"job1", "job2", "job3"}
         all_jobs = default_jobs | {postsubmit_job}
         is_pr = True
-        modifies = True
+        modified_paths = ["runtime/file"]
         jobs = configure_ci.get_enabled_jobs(
             trailers,
             all_jobs,
-            modifies=modifies,
+            modified_paths=modified_paths,
             is_pr=is_pr,
         )
         self.assertCountEqual(jobs, all_jobs)
 
     def test_get_enabled_jobs_exactly(self):
-        postsubmit_job = next(iter(configure_ci.POSTSUBMIT_ONLY_JOBS))
+        postsubmit_job = next(iter(configure_ci.DEFAULT_POSTSUBMIT_ONLY_JOBS))
         trailers = {configure_ci.Trailer.EXACTLY_JOBS: postsubmit_job}
         default_jobs = {"job1", "job2", "job3"}
         all_jobs = default_jobs | {postsubmit_job}
         is_pr = True
-        modifies = True
+        modified_paths = ["runtime/file"]
         jobs = configure_ci.get_enabled_jobs(
             trailers,
             all_jobs,
-            modifies=modifies,
+            modified_paths=modified_paths,
             is_pr=is_pr,
         )
         self.assertCountEqual(jobs, {postsubmit_job})
 
+    def test_get_enabled_jobs_metal(self):
+        trailers = {}
+        all_jobs = {"job1"}
+        is_pr = True
+        modified_paths = ["runtime/src/iree/hal/drivers/metal/file"]
+        jobs = configure_ci.get_enabled_jobs(
+            trailers,
+            all_jobs,
+            modified_paths=modified_paths,
+            is_pr=is_pr,
+        )
+        expected_jobs = {"job1", "build_test_all_macos_arm64"}
+        self.assertCountEqual(jobs, expected_jobs)
+
     def test_parse_path_from_workflow_ref(self):
         path = configure_ci.parse_path_from_workflow_ref(
             "octocat/example", "octocat/example/.github/test.yml@1234"