blob: ebfe340897f0d62d441c243d4080fa8328354630 [file]
#!/usr/bin/env python3
# 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 affected CI suites; share the preflight runner and configuration."""
import json
import os
import subprocess
from pathlib import Path
from version import release_config
ROOT = Path(__file__).resolve().parents[1]
MODULES = {
"client": "hugegraph-python-client",
"sdk": "hugegraph-llm-sdk",
"llm": "hugegraph-llm",
"mcp": "hugegraph-mcp",
}
FULL_PYTHONS = {
"client": ["3.12", "3.13", "3.14"],
"sdk": ["3.12", "3.13", "3.14"],
"llm": ["3.12", "3.13", "3.14"],
"mcp": ["3.12", "3.13", "3.14"],
}
SHARED = {
"pyproject.toml",
"uv.lock",
".github/workflows/ci.yml",
"scripts/ci.py",
"scripts/version.py",
"scripts/check_hugegraph.sh",
}
def select(paths: list[str], *, full: bool = False, test_set: str = "mainline") -> dict[str, bool]:
flags = dict.fromkeys((*MODULES, "quality", "dependencies", "codeql", "license"), False)
if full:
return dict.fromkeys(flags, True)
if test_set in ("codeql", "dependencies"):
flags[test_set] = True
return flags
for path in paths:
is_pyproject = Path(path).name == "pyproject.toml"
is_documentation = Path(path).suffix.lower() in (".md", ".rst")
# Module documentation/examples are inputs to contract tests too.
in_module = any(path.startswith(module + "/") for module in MODULES.values())
if is_documentation and not in_module:
continue
if not is_documentation:
flags["license"] = True
if path == ".gitattributes":
flags["client"] = True # Owns the source-archive build/install guard.
if path in SHARED or path.startswith(".github/actions/"):
for key in (*MODULES, "quality"):
flags[key] = True
for key, module in MODULES.items():
if path.startswith(module + "/") or path == f".github/workflows/{module}.yml":
flags[key] = True
if path == ".dockerignore" or path.startswith("docker/") or path == "scripts/build_llm_image.sh":
flags["llm"] = True
if (
is_pyproject
or path.endswith((".py", ".pyi"))
or path in (".pre-commit-config.yaml", ".github/workflows/ruff.yml")
):
flags["quality"] = True
if path.endswith((".py", ".pyi")):
flags["codeql"] = True
if Path(path).name in ("pyproject.toml", "uv.lock") or Path(path).name.startswith("requirements"):
flags["dependencies"] = True
flags["codeql"] = True
if path == ".github/workflows/codeql.yml":
flags["codeql"] = True
if path == ".github/workflows/check-dependencies.yml":
flags["dependencies"] = True
if flags["client"]:
flags["llm"] = flags["mcp"] = True
if flags["sdk"]:
flags["llm"] = True
return flags
def changed_paths(event: dict, event_name: str) -> list[str]:
base = event["pull_request"]["base"]["sha"] if event_name == "pull_request" else event.get("before")
if not base or set(base) == {"0"}:
# Manual/new-branch runs validate the current tree instead of guessing.
result = subprocess.run(["git", "ls-files", "-z"], check=True, capture_output=True)
else:
# Include both sides of renames, and fail rather than silently skip on a bad diff.
result = subprocess.run(
["git", "diff", "--no-renames", "--name-only", "-z", base, "HEAD"], check=True, capture_output=True
)
return [path for path in result.stdout.decode().split("\0") if path]
def codeql_provider(selected: dict[str, bool], repository: str) -> str:
"""Keep GitHub default setup as the single scanner when it is active."""
if not selected["codeql"]:
return "not-requested"
# Unlike code-scanning settings, workflow metadata only requires actions:read.
# Paginate so the default workflow cannot be missed in a large repository.
raw = subprocess.check_output(
["gh", "api", "--paginate", "--slurp", f"repos/{repository}/actions/workflows?per_page=100"],
text=True,
)
pages = json.loads(raw)
if not isinstance(pages, list) or not pages:
raise ValueError("Invalid GitHub workflow metadata while selecting CodeQL provider")
for page in pages:
for workflow in page["workflows"]:
if workflow["path"] == "dynamic/github-code-scanning/codeql" and workflow["state"] == "active":
selected["codeql"] = False
return "github-default-setup"
return "advanced-workflow"
def main() -> None:
event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text())
event_name = os.environ["GITHUB_EVENT_NAME"]
test_set = os.environ.get("TEST_SET", "mainline")
full = (
event_name == "schedule"
or test_set == "full"
or (event_name == "push" and os.environ.get("GITHUB_REF", "").startswith("refs/heads/release-"))
)
paths = changed_paths(event, event_name)
selected = select(paths, full=full, test_set=test_set)
provider = codeql_provider(selected, os.environ["GITHUB_REPOSITORY"])
config = release_config(ROOT)
outputs = {key: str(value).lower() for key, value in selected.items()}
outputs.update(config)
outputs["full"] = str(full).lower()
for key in MODULES:
outputs[key + "-pythons"] = json.dumps(FULL_PYTHONS[key] if full else [config["python-version"]])
for key, value in outputs.items():
if "\n" in str(value) or "\r" in str(value):
raise ValueError(f"Output {key} must be a single line")
with Path(os.environ["GITHUB_OUTPUT"]).open("a") as stream:
for key, value in outputs.items():
stream.write(f"{key}={value}\n")
summary = json.dumps(
{"full": full, "codeql_provider": provider, "suites": selected, "changed_files": paths}, indent=2
)
Path(os.environ["GITHUB_STEP_SUMMARY"]).write_text(f"### CI selection\n\n```json\n{summary}\n```\n")
if __name__ == "__main__":
main()