This is an automated email from the ASF dual-hosted git repository.

Jefffrey pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git


The following commit(s) were added to refs/heads/main by this push:
     new 1b68589d69 ci: enable merge queue and run Miri only in the queue 
(#11097)
1b68589d69 is described below

commit 1b68589d69848552f9b32866f71633fe92114596
Author: Dmitrii Blaginin <[email protected]>
AuthorDate: Fri Oct 2 05:28:35 2026 +0100

    ci: enable merge queue and run Miri only in the queue (#11097)
    
    - Closes https://github.com/apache/arrow-rs/issues/11045
    - closes https://github.com/apache/arrow-rs/issues/8422
    
    Add merge queue to arrow-rs and move miri to run there (and not on every
    push)
    
    ---------
    
    Co-authored-by: Andrew Lamb <[email protected]>
---
 .asf.yaml                             |  40 ++++-
 .github/ci/paths.yaml                 | 106 +++++++++++++
 .github/ci/scripts/check-ci-config.py | 270 ++++++++++++++++++++++++++++++++++
 .github/ci/scripts/compute-changes.py |  76 ++++++++++
 .github/workflows/README.md           |  11 +-
 .github/workflows/arrow.yml           |  31 +---
 .github/workflows/arrow_flight.yml    |  22 +--
 .github/workflows/audit.yml           |  15 +-
 .github/workflows/ci.yml              | 147 ++++++++++++++++++
 .github/workflows/dev.yml             |  11 +-
 .github/workflows/docs.yml            |  11 +-
 .github/workflows/integration.yml     |  60 +++-----
 .github/workflows/miri.yaml           |  34 +----
 .github/workflows/parquet.yml         |  54 ++-----
 .github/workflows/rust.yml            |  11 +-
 15 files changed, 691 insertions(+), 208 deletions(-)

diff --git a/.asf.yaml b/.asf.yaml
index 56980884a4..910b13f909 100644
--- a/.asf.yaml
+++ b/.asf.yaml
@@ -41,15 +41,47 @@ github:
   protected_branches:
     main:
       required_status_checks:
-        # require branches to be up-to-date before merging
-        strict: true
-        # don't require any jobs to pass
-        contexts: []
+        # The merge queue tests each PR against the latest main.
+        strict: false
+        # This job aggregates all suites in .github/workflows/ci.yml.
+        contexts:
+          - "Required Checks"
       required_pull_request_reviews:
         required_approving_review_count: 1
+  # Raw GitHub Rulesets API payload, as in apache/datafusion-comet.
+  # Rulesets and classic branch protection apply together.
+  rulesets:
+    - name: Merge Queue
+      target: branch
+      enforcement: active
+      bypass_actors:
+        # apache/root (ASF Infra) can recover a blocked queue.
+        - actor_id: 118420
+          actor_type: Team
+          bypass_mode: always
+      conditions:
+        ref_name:
+          include:
+            - "~DEFAULT_BRANCH"
+          exclude: []
+      rules:
+        - type: merge_queue
+          parameters:
+            merge_method: SQUASH
+            grouping_strategy: ALLGREEN
+            # Limit concurrent CI builds while allowing speculative testing.
+            max_entries_to_build: 2
+            # Merge up to five already-green entries together. Each PR still
+            # gets its own squash commit; this does not batch CI builds.
+            min_entries_to_merge: 1
+            max_entries_to_merge: 5
+            min_entries_to_merge_wait_minutes: 0
+            # Allow for the full test suite and ASF runner scheduling delays.
+            check_response_timeout_minutes: 300
   pull_requests:
     # enable updating head branches of pull requests
     allow_update_branch: true
+    allow_auto_merge: true
 
 # publishes the content of the `asf-site` branch to
 # https://arrow.apache.org/rust/
diff --git a/.github/ci/paths.yaml b/.github/ci/paths.yaml
new file mode 100644
index 0000000000..f89ca2fe2c
--- /dev/null
+++ b/.github/ci/paths.yaml
@@ -0,0 +1,106 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Shared build inputs select all suites on pull requests.
+common:
+  - .asf.yaml
+  - .github/**
+  - .gitmodules
+  - .config/**
+  - Cargo.toml
+  - Cargo.lock
+  - rust-toolchain.toml
+  - testing
+  - parquet-testing
+  - format/**
+suites:
+  arrow:
+    - arrow-arith/**
+    - arrow-array/**
+    - arrow-buffer/**
+    - arrow-cast/**
+    - arrow-cmp/**
+    - arrow-csv/**
+    - arrow-data/**
+    - arrow-integration-test/**
+    - arrow-integration-testing/**
+    - arrow-ipc/**
+    - arrow-json/**
+    - arrow-avro/**
+    - arrow-ord/**
+    - arrow-row/**
+    - arrow-schema/**
+    - arrow-select/**
+    - arrow-string/**
+    - arrow/**
+  arrow-flight:
+    - arrow-array/**
+    - arrow-buffer/**
+    - arrow-cast/**
+    - arrow-data/**
+    - arrow-flight/**
+    - arrow-ipc/**
+    - arrow-schema/**
+    - arrow-select/**
+  parquet:
+    - arrow/**
+    - arrow-arith/**
+    - arrow-array/**
+    - arrow-buffer/**
+    - arrow-cast/**
+    - arrow-cmp/**
+    - arrow-csv/**
+    - arrow-data/**
+    - arrow-ipc/**
+    - arrow-json/**
+    - arrow-ord/**
+    - arrow-row/**
+    - arrow-schema/**
+    - arrow-select/**
+    - arrow-string/**
+    - parquet/**
+    - parquet_derive/**
+    - parquet_derive_test/**
+    - parquet-geospatial/**
+    - parquet-variant/**
+    - parquet-variant-compute/**
+    - parquet-variant-json/**
+  integration:
+    - arrow-arith/**
+    - arrow-array/**
+    - arrow-buffer/**
+    - arrow-cast/**
+    - arrow-cmp/**
+    - arrow-csv/**
+    - arrow-data/**
+    - arrow-flight/**
+    - arrow-integration-test/**
+    - arrow-integration-testing/**
+    - arrow-ipc/**
+    - arrow-json/**
+    - arrow-ord/**
+    - arrow-pyarrow-integration-testing/**
+    - arrow-pyarrow-testing/**
+    - arrow-pyarrow/**
+    - arrow-row/**
+    - arrow-schema/**
+    - arrow-select/**
+    - arrow-string/**
+    - arrow/**
+  audit:
+    - '**/Cargo.toml'
+    - '**/Cargo.lock'
diff --git a/.github/ci/scripts/check-ci-config.py 
b/.github/ci/scripts/check-ci-config.py
new file mode 100644
index 0000000000..2648251b4f
--- /dev/null
+++ b/.github/ci/scripts/check-ci-config.py
@@ -0,0 +1,270 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Check CI configuration and routing policy, following Comet's structure.
+
+Run from the repository root:
+    python3 .github/ci/scripts/check-ci-config.py
+"""
+
+import contextlib
+import importlib.util
+import io
+import json
+import os
+from pathlib import Path
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+import tomllib
+import unittest
+
+spec = importlib.util.spec_from_file_location(
+    "compute_changes", Path(__file__).with_name("compute-changes.py")
+)
+changes = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(changes)
+
+ROOT = changes.ROOT
+ALWAYS = changes.ALWAYS
+load_yaml = changes.load_yaml
+
+# Only these jobs and shell steps should run when warming main's caches.
+CACHE_REFRESH_STEPS = {
+    "integration": {
+        "pyarrow-integration-test": {
+            "Setup Rust toolchain", "Upgrade pip and setuptools",
+            "Create virtualenv and install dependencies", "Build Python 
extension",
+        },
+    },
+    "parquet": {"pyspark-integration-test": {"Install Python dependencies"}},
+}
+FULL_RUN = "${{ !inputs.cache-refresh-only }}"
+
+
+def validate_cache_refresh(ci, suite, workflow):
+    expected = "${{ needs.changes.outputs.cache-refresh-only == 'true' }}"
+    assert ci["jobs"][suite].get("with", {}).get("cache-refresh-only") == 
expected, f"{suite}: missing cache mode input"
+    cache_jobs = CACHE_REFRESH_STEPS[suite]
+    assert cache_jobs.keys() <= workflow["jobs"].keys(), f"{suite}: missing 
cache writer"
+    for name, job in workflow["jobs"].items():
+        if name not in cache_jobs:
+            assert job.get("if") == FULL_RUN, f"{suite}/{name}: runs during 
cache refresh"
+            continue
+        assert "if" not in job, f"{suite}/{name}: cache writer must run in 
both modes"
+        run_steps = {step["name"]: step for step in job["steps"] if "run" in 
step}
+        assert cache_jobs[name] <= run_steps.keys(), f"{suite}/{name}: missing 
cache setup step"
+        for step in job["steps"]:
+            if "run" not in step or step["name"] in cache_jobs[name]:
+                assert "if" not in step, f"{suite}/{name}: cache setup must 
run in both modes"
+            else:
+                assert step.get("if") == FULL_RUN, 
f"{suite}/{name}/{step['name']}: runs during cache refresh"
+
+
+def validate_package_paths(root, suite, workflow, filters):
+    """Check literal Cargo package arguments, without interpreting shell 
scripts."""
+    packages = {}
+    for manifest in root.glob("*/Cargo.toml"):
+        package = tomllib.loads(manifest.read_text()).get("package")
+        if package:
+            packages[package["name"]] = 
manifest.parent.relative_to(root).as_posix()
+    for job in workflow["jobs"].values():
+        for step in job.get("steps", []):
+            for command in re.findall(r"\bcargo\b[^\n]*", step.get("run", "")):
+                for name in 
re.findall(r"(?:^|\s)(?:-p|--package)(?:\s+|=)[\"']?([\w-]+)", command):
+                    assert name in packages, f"{suite}: unknown local package 
{name}"
+                    path = f"{packages[name]}/src/lib.rs"
+                    selected = changes.select_suites("pull_request", [path], 
filters)
+                    assert suite in selected, f"{suite}: changes to {name} do 
not select its tests"
+
+
+def validate_config(root=ROOT):
+    """Catch stale check names, omitted suites, and workflow-level path 
filters."""
+    workflows = root / ".github/workflows"
+    ci = load_yaml(workflows / "ci.yml")
+    filters = load_yaml(root / ".github/ci/paths.yaml")
+    jobs = ci["jobs"]
+    suites = set(filters["suites"]) | ALWAYS | {"miri"}
+    assert not (set(filters["suites"]) & (ALWAYS | {"miri"})), "Unexpected 
filtered suite"
+    assert set(jobs) == suites | {"changes", "required-checks"}, "CI suites 
and jobs differ"
+    # PyYAML's YAML 1.1 loader treats the key 'on' as the boolean True.
+    events = ci[True]
+    assert jobs["changes"]["outputs"].get("cache-refresh-only") == "${{ 
steps.select.outputs.cache-refresh-only }}", "Missing cache mode output"
+    assert set(events) == {"pull_request", "push", "merge_group"}, "Unexpected 
CI triggers"
+    assert events["pull_request"] is None, "Required CI must run on every PR"
+    assert events["merge_group"] == {"types": ["checks_requested"]}, "Missing 
queue trigger"
+    assert events["push"] == {"branches": ["main"]}, "Only main pushes should 
run CI"
+
+    gate = jobs["required-checks"]
+    assert set(gate["needs"]) == set(jobs) - {"required-checks"}, "Required 
Checks omits a job"
+    assert gate["if"] == "always()", "Required Checks must run after failures 
and cancellations"
+    asf = load_yaml(root / ".asf.yaml")["github"]
+    checks = asf["protected_branches"]["main"]["required_status_checks"]
+    assert checks["contexts"] == [gate["name"]], "Required check name does not 
match .asf.yaml"
+    assert checks["strict"] is False, "The queue handles up-to-date checks"
+
+    called = set()
+    for suite in suites:
+        job = jobs[suite]
+        assert job["needs"] == "changes", f"{suite}: missing suite selection 
dependency"
+        condition = f"contains(fromJSON(needs.changes.outputs.suites), 
'{suite}')"
+        assert job["if"] == condition, f"{suite}: unexpected routing condition"
+        path = root / job["uses"]
+        workflow = load_yaml(path)
+        call = None
+        if suite in CACHE_REFRESH_STEPS:
+            call = {"inputs": {"cache-refresh-only": {
+                "description": "Populate caches without running tests or 
linters",
+                "type": "boolean", "default": False,
+            }}}
+            validate_cache_refresh(ci, suite, workflow)
+        assert workflow[True] == {"workflow_call": call}, f"{suite}: duplicate 
or missing triggers/inputs"
+        assert "concurrency" not in workflow, f"{suite}: concurrency is owned 
by ci.yml"
+        if suite in filters["suites"]:
+            validate_package_paths(root, suite, workflow, filters)
+        called.add(path.resolve())
+    reusable = {
+        path.resolve()
+        for path in workflows.iterdir()
+        if path.suffix in {".yml", ".yaml"}
+        and "workflow_call" in load_yaml(path).get(True, {})
+    }
+    assert called == reusable, "A reusable workflow is missing from Required 
Checks"
+    miri = load_yaml(workflows / "miri.yaml")["jobs"]["miri-checks"]
+    assert "if" not in miri, "Miri event routing belongs in compute-changes.py"
+    print("CI configuration is valid")
+
+
+class RoutingTests(unittest.TestCase):
+    def setUp(self):
+        self.filters = changes.load_yaml(changes.ROOT / 
".github/ci/paths.yaml")
+        self.always = {"dev", "rust", "docs"}
+        self.all_suites = self.always | {
+            "arrow", "arrow-flight", "parquet", "integration", "audit",
+        }
+
+    def select(self, event, paths):
+        return set(changes.select_suites(event, paths, self.filters))
+
+    def test_event_policy(self):
+        for paths in [[], ["README.md"], ["arrow-buffer/src/lib.rs"]]:
+            with self.subTest(paths=paths):
+                self.assertEqual(self.select("merge_group", paths), 
self.all_suites | {"miri"})
+                self.assertEqual(self.select("push", paths), {"docs", 
"integration", "parquet"})
+                self.assertNotIn("miri", self.select("pull_request", paths))
+        with self.assertRaises(ValueError):
+            self.select("pull_request_target", [])
+
+    def test_selector_outputs(self):
+        for event, expected in [("push", {"docs", "integration", "parquet"}),
+                                ("merge_group", self.all_suites | {"miri"}),
+                                ("pull_request", self.always)]:
+            with self.subTest(event=event), tempfile.TemporaryDirectory() as 
directory:
+                output = Path(directory) / "output"
+                env = dict(os.environ, GITHUB_EVENT_NAME=event, 
GITHUB_OUTPUT=str(output),
+                           PR_BASE_SHA="HEAD", PR_HEAD_SHA="HEAD")
+                subprocess.run([sys.executable, str(ROOT / 
".github/ci/scripts/compute-changes.py")],
+                               env=env, check=True, capture_output=True)
+                values = dict(line.split("=", 1) for line in 
output.read_text().splitlines())
+                self.assertEqual(set(json.loads(values["suites"])), expected)
+                self.assertEqual(json.loads(values["cache-refresh-only"]), 
event == "push")
+
+    def test_pr_path_routing(self):
+        cases = [
+            ([], set()),
+            (["README.md"], set()),
+            (["arrow-buffer/src/lib.rs"], {"arrow", "arrow-flight", "parquet", 
"integration"}),
+            (["arrow-avro/src/lib.rs"], {"arrow"}),
+            (["arrow-flight/src/lib.rs"], {"arrow-flight", "integration"}),
+            (["arrow-pyarrow/src/lib.rs"], {"integration"}),
+            (["arrow-integration-testing/src/lib.rs"], {"arrow", 
"integration"}),
+            (["arrow-pyarrow-testing/src/lib.rs"], {"integration"}),
+            (["parquet/src/lib.rs"], {"parquet"}),
+            (["parquet_derive/src/lib.rs"], {"parquet"}),
+            (["parquet_derive_test/src/lib.rs"], {"parquet"}),
+            (["parquet-variant-json/src/lib.rs"], {"parquet"}),
+            (["parquet-geospatial/src/lib.rs"], {"parquet"}),
+            (["arrow-row/Cargo.toml"], {"arrow", "parquet", "integration", 
"audit"}),
+            (["some/nested/Cargo.lock"], {"audit"}),
+            (["parquet-variant/src/lib.rs"], {"parquet"}),
+            (["parquet-variant-compute/src/lib.rs"], {"parquet"}),
+            (["arrow-arith/src/lib.rs"], {"arrow", "parquet", "integration"}),
+            (["arrow-ord/src/lib.rs"], {"arrow", "parquet", "integration"}),
+            (["arrow-string/src/lib.rs"], {"arrow", "parquet", "integration"}),
+            # A move or deletion must still select the affected suites.
+            (["arrow-cmp/old.rs", "parquet-geospatial/new.rs"], {"arrow", 
"parquet", "integration"}),
+            (["arrow-cmp/file\nwith-newline.rs"], {"arrow", "parquet", 
"integration"}),
+        ]
+        for paths, expected in cases:
+            with self.subTest(paths=paths):
+                self.assertEqual(self.select("pull_request", paths), 
self.always | expected)
+
+    def test_shared_build_inputs(self):
+        for path in [
+            ".asf.yaml", ".github/workflows/ci.yml", ".github/ci/paths.yaml",
+            ".github/actions/setup-builder/action.yaml", ".gitmodules",
+            "Cargo.toml", "Cargo.lock", "rust-toolchain.toml",
+            "testing", "parquet-testing", "format/Flight.proto", 
"format/FlightSql.proto",
+            ".config/nextest.toml",
+        ]:
+            with self.subTest(path=path):
+                self.assertEqual(self.select("pull_request", [path]), 
self.all_suites)
+
+
+class ConfigurationTests(unittest.TestCase):
+    def test_current_configuration(self):
+        with contextlib.redirect_stdout(io.StringIO()):
+            validate_config()
+
+    def test_configuration_regressions(self):
+        cases = [
+            (".asf.yaml", '"Required Checks"', '"Missing Check"'),
+            (".github/workflows/ci.yml", "      - miri\n", ""),
+            (".github/workflows/ci.yml", "  pull_request:\n", "  
pull_request:\n    paths: ['arrow/**']\n"),
+            (".github/workflows/ci.yml", "    if: always()", "    if: 
success()"),
+            (".github/workflows/miri.yaml", "    name: MIRI\n", "    name: 
MIRI\n    if: github.event_name == 'merge_group'\n"),
+            (".github/workflows/arrow.yml", "  workflow_call:", "  
workflow_call:\n  pull_request:"),
+            (".github/ci/paths.yaml", "    - arrow-integration-testing/**\n", 
""),
+            (".github/workflows/ci.yml", "      cache-refresh-only: ${{ 
steps.select.outputs.cache-refresh-only }}\n", ""),
+            (".github/workflows/ci.yml", "    with:\n      cache-refresh-only: 
${{ needs.changes.outputs.cache-refresh-only == 'true' }}\n", ""),
+            (".github/workflows/parquet.yml", "\n    if: ${{ 
!inputs.cache-refresh-only }}\n", "\n"),
+            (".github/workflows/integration.yml", "\n    if: ${{ 
!inputs.cache-refresh-only }}\n", "\n"),
+            (".github/workflows/parquet.yml", "        if: ${{ 
!inputs.cache-refresh-only }}\n", ""),
+            (".github/workflows/integration.yml", "        if: ${{ 
!inputs.cache-refresh-only }}\n", ""),
+            (".github/workflows/integration.yml", "      - name: Build Python 
extension\n", "      - name: Build Python extension\n        if: ${{ 
!inputs.cache-refresh-only }}\n"),
+        ]
+        for filename, before, after in cases:
+            with self.subTest(filename=filename, before=before), 
tempfile.TemporaryDirectory() as directory:
+                root = Path(directory)
+                shutil.copytree(changes.ROOT / ".github", root / ".github")
+                shutil.copy(changes.ROOT / ".asf.yaml", root / ".asf.yaml")
+                for manifest in changes.ROOT.glob("*/Cargo.toml"):
+                    target = root / manifest.relative_to(changes.ROOT)
+                    target.parent.mkdir(parents=True, exist_ok=True)
+                    shutil.copy(manifest, target)
+                path = root / filename
+                original = path.read_text()
+                self.assertIn(before, original)
+                path.write_text(original.replace(before, after))
+                with self.assertRaises(AssertionError):
+                    validate_config(root)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/.github/ci/scripts/compute-changes.py 
b/.github/ci/scripts/compute-changes.py
new file mode 100644
index 0000000000..0b8706206c
--- /dev/null
+++ b/.github/ci/scripts/compute-changes.py
@@ -0,0 +1,76 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Select CI suites from the event and changed PR paths."""
+
+import fnmatch
+import json
+import os
+from pathlib import Path
+import subprocess
+
+ROOT = Path(__file__).resolve().parents[3]
+ALWAYS = {"dev", "rust", "docs"}
+
+
+def load_yaml(path):
+    import yaml
+
+    return yaml.safe_load(path.read_text())
+
+
+def select_suites(event, paths, filters):
+    suites = set(filters["suites"]) | ALWAYS
+    if event == "merge_group":
+        return sorted(suites | {"miri"})
+    if event == "push":
+        # The queue already tested the merge result. Publish docs and populate
+        # main's caches through the reusable workflows' cache-refresh-only 
mode.
+        return ["docs", "integration", "parquet"]
+    if event != "pull_request":
+        raise ValueError(f"Unsupported CI event: {event}")
+
+    def matches(patterns):
+        return any(fnmatch.fnmatchcase(path, pattern) for path in paths for 
pattern in patterns)
+
+    if matches(filters["common"]):
+        return sorted(suites)
+    return sorted(ALWAYS | {name for name, patterns in 
filters["suites"].items() if matches(patterns)})
+
+
+def main():
+    event = os.environ["GITHUB_EVENT_NAME"]
+    paths = []
+    if event == "pull_request":
+        base = os.environ["PR_BASE_SHA"]
+        head = os.environ["PR_HEAD_SHA"]
+        # Full checkout history includes both PR parents. Disable rename
+        # detection so moving a file selects both its old and new suites.
+        changed = subprocess.check_output(
+            ["git", "diff", "--no-renames", "--name-only", "-z", 
f"{base}...{head}", "--"],
+            cwd=ROOT,
+        )
+        paths = changed.decode("utf-8", 
errors="surrogateescape").split("\0")[:-1]
+    suites = select_suites(event, paths, load_yaml(ROOT / 
".github/ci/paths.yaml"))
+    with open(os.environ["GITHUB_OUTPUT"], "a") as output:
+        output.write(f"suites={json.dumps(suites)}\n")
+        output.write(f"cache-refresh-only={json.dumps(event == 'push')}\n")
+    print(f"Selected suites: {', '.join(suites)}")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/.github/workflows/README.md b/.github/workflows/README.md
index cfdd6d143c..d5c5d915c6 100644
--- a/.github/workflows/README.md
+++ b/.github/workflows/README.md
@@ -17,10 +17,7 @@
   under the License.
 -->
 
-The CI is structured so most tests are run in specific workflows:
-`arrow.yml` for `arrow`, `parquet.yml` for `parquet` and the three
-`parquet-variant*` crates, and so on.
-
-The basic idea is to run all tests on pushes to main (to ensure we
-keep main green) but run only the individual workflows on PRs that
-change files that could affect them.
+CI runs a subset of checks on pull requests, selected by the changed files.
+The merge queue runs the full test suite against the proposed merge result.
+Pushes to `main` populate caches and publish documentation without repeating 
tests.
+A single required status check combines the results and blocks merging on 
failure.
diff --git a/.github/workflows/arrow.yml b/.github/workflows/arrow.yml
index 71d4bda10f..a23ac9c214 100644
--- a/.github/workflows/arrow.yml
+++ b/.github/workflows/arrow.yml
@@ -18,36 +18,9 @@
 # tests for arrow crate
 name: arrow
 
-concurrency:
-  group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ 
github.workflow }}
-  cancel-in-progress: true
-
+# Triggered by ci.yml, which owns event routing and concurrency.
 on:
-  # always trigger
-  push:
-    branches:
-      - main
-  pull_request:
-    paths:
-      - .github/**
-      - rust-toolchain.toml
-      - arrow-arith/**
-      - arrow-array/**
-      - arrow-buffer/**
-      - arrow-cast/**
-      - arrow-cmp/**
-      - arrow-csv/**
-      - arrow-data/**
-      - arrow-integration-test/**
-      - arrow-ipc/**
-      - arrow-json/**
-      - arrow-avro/**
-      - arrow-ord/**
-      - arrow-row/**
-      - arrow-schema/**
-      - arrow-select/**
-      - arrow-string/**
-      - arrow/**
+  workflow_call:
 
 permissions:
   contents: read
diff --git a/.github/workflows/arrow_flight.yml 
b/.github/workflows/arrow_flight.yml
index 1e158dabe3..f11b0d1b41 100644
--- a/.github/workflows/arrow_flight.yml
+++ b/.github/workflows/arrow_flight.yml
@@ -19,27 +19,9 @@
 # tests for arrow_flight crate
 name: arrow_flight
 
-concurrency:
-  group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ 
github.workflow }}
-  cancel-in-progress: true
-
-# trigger for all PRs that touch certain files and changes to main
+# Triggered by ci.yml, which owns event routing and concurrency.
 on:
-  push:
-    branches:
-      - main
-  pull_request:
-    paths:
-      - rust-toolchain.toml
-      - arrow-array/**
-      - arrow-buffer/**
-      - arrow-cast/**
-      - arrow-data/**
-      - arrow-flight/**
-      - arrow-ipc/**
-      - arrow-schema/**
-      - arrow-select/**
-      - .github/**
+  workflow_call:
 
 permissions:
   contents: read
diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml
index aec6c9db53..7a6b183d21 100644
--- a/.github/workflows/audit.yml
+++ b/.github/workflows/audit.yml
@@ -17,20 +17,9 @@
 
 name: audit
 
-concurrency:
-  group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ 
github.workflow }}
-  cancel-in-progress: true
-
-# trigger for all PRs that touch certain files and changes to main
+# Triggered by ci.yml, which owns event routing and concurrency.
 on:
-  push:
-    branches:
-      - main
-  pull_request:
-    paths:
-      - '**/Cargo.toml'
-      - '**/Cargo.lock'
-      - rust-toolchain.toml
+  workflow_call:
 
 permissions:
   contents: read
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000000..dcd624568c
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,147 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+name: CI
+
+on:
+  pull_request:
+  merge_group:
+    types: [checks_requested]
+  push:
+    branches: [main]
+
+concurrency:
+  group: ${{ github.repository }}-${{ github.event_name }}-${{ 
github.event.pull_request.number || github.sha }}-${{ github.workflow }}
+  cancel-in-progress: true
+
+permissions:
+  contents: read
+
+jobs:
+  changes:
+    name: Select and validate CI suites
+    runs-on: ubuntu-latest
+    outputs:
+      suites: ${{ steps.select.outputs.suites }}
+      cache-refresh-only: ${{ steps.select.outputs.cache-refresh-only }}
+    steps:
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # 
v7.0.1
+        with:
+          # PR routing uses the merge base, including changes from all commits.
+          fetch-depth: 0
+          persist-credentials: false
+      - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97  # 
v7.0.0
+        with:
+          python-version: '3.12'
+      - name: Install CI configuration dependency
+        run: |
+          for attempt in 1 2 3; do
+            python3 -m pip install PyYAML==6.0.3 && break
+            if [ "$attempt" -eq 3 ]; then exit 1; fi
+            sleep 5
+          done
+      - name: Validate CI configuration and routing
+        run: python3 .github/ci/scripts/check-ci-config.py
+      - name: Select suites
+        id: select
+        env:
+          PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
+          PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+        run: python3 .github/ci/scripts/compute-changes.py
+
+  arrow:
+    needs: changes
+    if: contains(fromJSON(needs.changes.outputs.suites), 'arrow')
+    uses: ./.github/workflows/arrow.yml
+
+  arrow-flight:
+    needs: changes
+    if: contains(fromJSON(needs.changes.outputs.suites), 'arrow-flight')
+    uses: ./.github/workflows/arrow_flight.yml
+
+  parquet:
+    needs: changes
+    if: contains(fromJSON(needs.changes.outputs.suites), 'parquet')
+    uses: ./.github/workflows/parquet.yml
+    with:
+      cache-refresh-only: ${{ needs.changes.outputs.cache-refresh-only == 
'true' }}
+
+  integration:
+    needs: changes
+    if: contains(fromJSON(needs.changes.outputs.suites), 'integration')
+    uses: ./.github/workflows/integration.yml
+    with:
+      cache-refresh-only: ${{ needs.changes.outputs.cache-refresh-only == 
'true' }}
+
+  audit:
+    needs: changes
+    if: contains(fromJSON(needs.changes.outputs.suites), 'audit')
+    uses: ./.github/workflows/audit.yml
+
+  dev:
+    needs: changes
+    if: contains(fromJSON(needs.changes.outputs.suites), 'dev')
+    uses: ./.github/workflows/dev.yml
+
+  rust:
+    needs: changes
+    if: contains(fromJSON(needs.changes.outputs.suites), 'rust')
+    uses: ./.github/workflows/rust.yml
+
+  docs:
+    needs: changes
+    if: contains(fromJSON(needs.changes.outputs.suites), 'docs')
+    # Only the deploy job in docs.yml requests write access, on push to main.
+    permissions:
+      contents: write
+    uses: ./.github/workflows/docs.yml
+
+  miri:
+    needs: changes
+    if: contains(fromJSON(needs.changes.outputs.suites), 'miri')
+    uses: ./.github/workflows/miri.yaml
+
+  required-checks:
+    # Like datafusion-comet: one stable check name even when a reusable
+    # workflow is skipped (its individual check names would be different).
+    name: Required Checks
+    if: always()
+    permissions: {}
+    needs:
+      - changes
+      - arrow
+      - arrow-flight
+      - parquet
+      - integration
+      - audit
+      - dev
+      - rust
+      - docs
+      - miri
+    runs-on: ubuntu-slim
+    steps:
+      - name: Summarize suite results
+        env:
+          NEEDS: ${{ toJSON(needs) }}
+        run: echo "$NEEDS"
+      # Path-filtered suites and Miri outside the queue are intentionally
+      # skipped. A failed/cancelled selector or suite must still block merging.
+      - name: Fail if any suite failed or was cancelled
+        if: needs.changes.result != 'success' || contains(needs.*.result, 
'failure') || contains(needs.*.result, 'cancelled')
+        run: |
+          echo "::error::One or more CI suites did not succeed. See the 
results above."
+          exit 1
diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml
index 6b548e5af2..786f3ded90 100644
--- a/.github/workflows/dev.yml
+++ b/.github/workflows/dev.yml
@@ -17,16 +17,9 @@
 
 name: dev
 
-concurrency:
-  group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ 
github.workflow }}
-  cancel-in-progress: true
-
-# trigger for all PRs and changes to main
+# Triggered by ci.yml, which owns event routing and concurrency.
 on:
-  push:
-    branches:
-      - main
-  pull_request:
+  workflow_call:
 
 permissions:
   contents: read
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 54f5387aa3..8b8753b15b 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -17,16 +17,9 @@
 
 name: docs
 
-concurrency:
-  group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ 
github.workflow }}
-  cancel-in-progress: true
-
-# trigger for all PRs and changes to main
+# Triggered by ci.yml, which owns event routing and concurrency.
 on:
-  push:
-    branches:
-      - main
-  pull_request:
+  workflow_call:
 
 jobs:
   # test doc links still work
diff --git a/.github/workflows/integration.yml 
b/.github/workflows/integration.yml
index 62a4b19f58..658081db89 100644
--- a/.github/workflows/integration.yml
+++ b/.github/workflows/integration.yml
@@ -17,45 +17,21 @@
 
 name: integration
 
-concurrency:
-  group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ 
github.workflow }}
-  cancel-in-progress: true
-
-# trigger for all PRs that touch certain files and changes to main
+# Triggered by ci.yml, which owns event routing and concurrency.
 on:
-  push:
-    branches:
-      - main
-  pull_request:
-    paths:
-      - .github/**
-      - rust-toolchain.toml
-      - arrow-arith/**
-      - arrow-array/**
-      - arrow-buffer/**
-      - arrow-cast/**
-      - arrow-cmp/**
-      - arrow-csv/**
-      - arrow-data/**
-      - arrow-flight/**
-      - arrow-integration-test/**
-      - arrow-integration-testing/**
-      - arrow-ipc/**
-      - arrow-json/**
-      - arrow-ord/**
-      - arrow-pyarrow-integration-testing/**
-      - arrow-pyarrow/**
-      - arrow-row/**
-      - arrow-schema/**
-      - arrow-select/**
-      - arrow-string/**
-      - arrow/**
+  workflow_call:
+    inputs:
+      cache-refresh-only:
+        description: Populate caches without running tests or linters
+        type: boolean
+        default: false
 
 permissions:
   contents: read
 
 jobs:
   integration:
+    if: ${{ !inputs.cache-refresh-only }}
     name: Archery test With other arrows
     runs-on: ubuntu-latest
     container:
@@ -198,7 +174,8 @@ jobs:
       matrix:
         rust: [stable]
         # PyArrow 15 was the first version to introduce StringView/BinaryView 
support
-        pyarrow: ["15", "16", "17"]
+        # The cache keys are shared across these versions; one build warms 
them.
+        pyarrow: ${{ inputs.cache-refresh-only && fromJSON('["17"]') || 
fromJSON('["15", "16", "17"]') }}
     steps:
       - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # 
v7.0.1
         with:
@@ -212,13 +189,15 @@ jobs:
         uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9  # v6.1.0
         with:
           path: /home/runner/.cargo
-          key: cargo-maturin-cache-
+          key: cargo-maturin-cache-${{ hashFiles('**/Cargo.toml', 
'rust-toolchain.toml') }}
+          restore-keys: cargo-maturin-cache-
       - name: Cache Rust dependencies
         uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9  # v6.1.0
         with:
-          path: /home/runner/target
+          path: arrow-pyarrow-integration-testing/target
           # this key is not equal because maturin uses different compilation 
flags.
-          key: ${{ runner.os }}-${{ matrix.arch }}-target-maturin-cache-${{ 
matrix.rust }}-
+          key: ${{ runner.os }}-${{ runner.arch }}-target-maturin-cache-${{ 
matrix.rust }}-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }}
+          restore-keys: ${{ runner.os }}-${{ runner.arch 
}}-target-maturin-cache-${{ matrix.rust }}-
       - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97  # 
v7.0.0
         with:
           python-version: '3.8'
@@ -230,6 +209,7 @@ jobs:
           source venv/bin/activate
           pip install maturin toml pytest pytz pyarrow==${{ matrix.pyarrow }}
       - name: Run Rust tests
+        if: ${{ !inputs.cache-refresh-only }}
         run: |
           source venv/bin/activate
           # arrow-pyarrow's own tests are feature-gated, so the workspace-wide 
`cargo test` in
@@ -239,6 +219,7 @@ jobs:
           cd arrow-pyarrow-testing
           cargo test
       - name: Clippy arrow-pyarrow
+        if: ${{ !inputs.cache-refresh-only }}
         run: |
           source venv/bin/activate
           # Here rather than in arrow.yml's clippy job: that one runs in a
@@ -248,9 +229,14 @@ jobs:
           # default features, so the --no-default-features variant arrow.yml
           # also runs would be this same command.
           cargo clippy -p arrow-pyarrow --all-features -- -D 
unused_crate_dependencies
-      - name: Run Python tests
+      - name: Build Python extension
         run: |
           source venv/bin/activate
           cd arrow-pyarrow-integration-testing
           maturin develop
+      - name: Run Python tests
+        if: ${{ !inputs.cache-refresh-only }}
+        run: |
+          source venv/bin/activate
+          cd arrow-pyarrow-integration-testing
           pytest -v .
diff --git a/.github/workflows/miri.yaml b/.github/workflows/miri.yaml
index a5133388cd..3e57aa142d 100644
--- a/.github/workflows/miri.yaml
+++ b/.github/workflows/miri.yaml
@@ -17,39 +17,9 @@
 
 name: miri
 
-concurrency:
-  group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ 
github.workflow }}
-  cancel-in-progress: true
-
-# trigger for all PRs that touch certain files and changes to main
+# Triggered by ci.yml, which owns event routing and concurrency.
 on:
-  push:
-    branches:
-      - main
-  pull_request:
-    paths:
-      - .config/nextest.toml
-      - Cargo.toml
-      - Cargo.lock
-      - .github/**
-      - rust-toolchain.toml
-      - arrow/**
-      - arrow-arith/**
-      - arrow-array/**
-      - arrow-avro/**
-      - arrow-buffer/**
-      - arrow-cast/**
-      - arrow-csv/**
-      - arrow-data/**
-      - arrow-ipc/**
-      - arrow-json/**
-      - arrow-cmp/**
-      - arrow-ord/**
-      - arrow-row/**
-      - arrow-schema/**
-      - arrow-select/**
-      - arrow-string/**
-      - parquet/**
+  workflow_call:
 
 permissions:
   contents: read
diff --git a/.github/workflows/parquet.yml b/.github/workflows/parquet.yml
index c1c8c6704c..380f456f4d 100644
--- a/.github/workflows/parquet.yml
+++ b/.github/workflows/parquet.yml
@@ -19,46 +19,14 @@
 # tests for parquet and related crates
 name: "parquet"
 
-concurrency:
-  group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ 
github.workflow }}
-  cancel-in-progress: true
-
-# trigger for all PRs that touch certain files and changes to main
+# Triggered by ci.yml, which owns event routing and concurrency.
 on:
-  push:
-    branches:
-      - main
-  pull_request:
-    paths:
-      - Cargo.toml
-      - Cargo.lock
-      - rust-toolchain.toml
-      - .gitmodules
-      - testing
-      - parquet-testing
-      - arrow/**
-      - arrow-arith/**
-      - arrow-array/**
-      - arrow-buffer/**
-      - arrow-cast/**
-      - arrow-cmp/**
-      - arrow-csv/**
-      - arrow-data/**
-      - arrow-ipc/**
-      - arrow-json/**
-      - arrow-ord/**
-      - arrow-row/**
-      - arrow-schema/**
-      - arrow-select/**
-      - arrow-string/**
-      - parquet/**
-      - parquet_derive/**
-      - parquet_derive_test/**
-      - parquet-geospatial/**
-      - parquet-variant/**
-      - parquet-variant-compute/**
-      - parquet-variant-json/**
-      - .github/**
+  workflow_call:
+    inputs:
+      cache-refresh-only:
+        description: Populate caches without running tests or linters
+        type: boolean
+        default: false
 
 permissions:
   contents: read
@@ -66,6 +34,7 @@ permissions:
 jobs:
   # test the crate
   linux-test:
+    if: ${{ !inputs.cache-refresh-only }}
     name: Test
     runs-on: ubuntu-latest
     container:
@@ -100,6 +69,7 @@ jobs:
 
   # test compilation
   linux-features:
+    if: ${{ !inputs.cache-refresh-only }}
     name: Check Compilation
     runs-on: ubuntu-latest
     container:
@@ -163,6 +133,7 @@ jobs:
 
   # test the parquet crate builds against wasm32 in stable rust
   wasm32-build:
+    if: ${{ !inputs.cache-refresh-only }}
     name: Build wasm32
     runs-on: ubuntu-latest
     container:
@@ -202,24 +173,29 @@ jobs:
           cd parquet/pytest
           pip install -r requirements.txt
       - name: Black check the test files
+        if: ${{ !inputs.cache-refresh-only }}
         run: |
           cd parquet/pytest
           black --check *.py --verbose
       - name: Setup Rust toolchain
+        if: ${{ !inputs.cache-refresh-only }}
         run: |
           rustup toolchain install ${{ matrix.rust }}
           rustup default ${{ matrix.rust }}
       - name: Install binary for checking
+        if: ${{ !inputs.cache-refresh-only }}
         run: |
           cargo install --path parquet --bin parquet-show-bloom-filter 
--features=cli
           cargo install --path parquet --bin parquet-fromcsv 
--features=arrow,cli
           cargo install --path parquet --bin parquet-index --features=cli
       - name: Run pytest
+        if: ${{ !inputs.cache-refresh-only }}
         run: |
           cd parquet/pytest
           pytest -v
 
   clippy:
+    if: ${{ !inputs.cache-refresh-only }}
     name: Clippy
     runs-on: ubuntu-latest
     container:
diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml
index 935548deda..9b1b5d3872 100644
--- a/.github/workflows/rust.yml
+++ b/.github/workflows/rust.yml
@@ -18,16 +18,9 @@
 # workspace wide tests
 name: rust
 
-concurrency:
-  group: ${{ github.repository }}-${{ github.head_ref || github.sha }}-${{ 
github.workflow }}
-  cancel-in-progress: true
-
-# trigger for all PRs and changes to main
+# Triggered by ci.yml, which owns event routing and concurrency.
 on:
-  push:
-    branches:
-      - main
-  pull_request:
+  workflow_call:
 
 permissions:
   contents: read

Reply via email to