| #!/usr/bin/env python3 |
| |
| # Copyright 2022 The IREE Authors |
| # |
| # Licensed under the Apache License v2.0 with LLVM Exceptions. |
| # See https://llvm.org/LICENSE.txt for license information. |
| # SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception |
| """Determines whether CI should run on a given PR. |
| |
| The following environment variables are required: |
| - GITHUB_EVENT_NAME: GitHub event name, e.g. pull_request. |
| - GITHUB_OUTPUT: path to write workflow output variables. |
| - GITHUB_STEP_SUMMARY: path to write workflow summary output. |
| |
| When GITHUB_EVENT_NAME is "pull_request", there are additional environment |
| variables to be set: |
| - PR_BRANCH (required): PR source branch. |
| - PR_TITLE (required): PR title. |
| - PR_BODY (optional): PR description. |
| - PR_LABELS (optional): JSON list of PR label names. |
| - BASE_REF (required): base commit SHA of the PR. |
| - ORIGINAL_PR_TITLE (optional): PR title from the original PR event, showing a |
| notice if PR_TITLE is different. |
| - ORIGINAL_PR_BODY (optional): PR description from the original PR event, |
| showing a notice if PR_BODY is different. ORIGINAL_PR_TITLE must also be |
| set. |
| - ORIGINAL_PR_LABELS (optional): PR labels from the original PR event, showing a |
| notice if PR_LABELS is different. ORIGINAL_PR_TITLE must also be set. |
| |
| Exit code 0 indicates that it should and exit code 2 indicates that it should |
| not. |
| """ |
| |
| import difflib |
| import fnmatch |
| import json |
| import os |
| import re |
| import subprocess |
| import textwrap |
| from typing import Iterable, List, Mapping, Sequence, Tuple |
| |
| SKIP_CI_KEY = "skip-ci" |
| RUNNER_ENV_KEY = "runner-env" |
| BENCHMARK_EXTRA_KEY = "benchmark-extra" |
| # Trailer to prevent benchmarks from always running on LLVM integration PRs. |
| SKIP_LLVM_INTEGRATE_BENCHMARK_KEY = "skip-llvm-integrate-benchmark" |
| |
| # Note that these are fnmatch patterns, which are not the same as gitignore |
| # patterns because they don't treat '/' specially. The standard library doesn't |
| # contain a function for gitignore style "wildmatch". There's a third-party |
| # library pathspec (https://pypi.org/project/pathspec/), but it doesn't seem |
| # worth the dependency. |
| SKIP_PATH_PATTERNS = [ |
| "docs/*", |
| "third_party/mkdocs-material/*", |
| "experimental/*", |
| "build_tools/buildkite/*", |
| # These configure the runners themselves and don't affect presubmit. |
| "build_tools/github_actions/runner/*", |
| ".github/ISSUE_TEMPLATE/*", |
| "*.cff", |
| "*.clang-format", |
| "*.git-ignore", |
| "*.md", |
| "*.natvis", |
| "*.pylintrc", |
| "*.rst", |
| "*.toml", |
| "*.yamllint.yml", |
| "*.yapf", |
| "*CODEOWNERS", |
| "*AUTHORS", |
| "*LICENSE", |
| ] |
| |
| RUNNER_ENV_DEFAULT = "prod" |
| RUNNER_ENV_OPTIONS = [RUNNER_ENV_DEFAULT, "testing"] |
| |
| DEFAULT_BENCHMARK_PRESET_GROUP = [ |
| "cuda", "x86_64", "android-cpu", "android-gpu", "vulkan-nvidia", |
| "comp-stats" |
| ] |
| DEFAULT_BENCHMARK_PRESET = "default" |
| LARGE_BENCHMARK_PRESET_GROUP = ["cuda-large", "x86_64-large"] |
| # All available benchmark preset options including experimental presets. |
| BENCHMARK_PRESET_OPTIONS = (DEFAULT_BENCHMARK_PRESET_GROUP + |
| LARGE_BENCHMARK_PRESET_GROUP) |
| BENCHMARK_LABEL_PREFIX = "benchmarks" |
| |
| PR_DESCRIPTION_TEMPLATE = "{title}" "\n\n" "{body}" |
| |
| # Patterns to detect "LLVM integration" PRs, i.e. changes that update the |
| # third_party/llvm-project submodule. This should only include PRs |
| # intended to be merged and should exclude test/draft PRs as well as |
| # PRs that include temporary patches to the submodule during review. |
| # See also: https://github.com/openxla/iree/issues/12268 |
| LLVM_INTEGRATE_TITLE_PATTERN = re.compile("^integrate.+llvm-project", |
| re.IGNORECASE) |
| LLVM_INTEGRATE_BRANCH_PATTERN = re.compile("bump-llvm|llvm-bump", re.IGNORECASE) |
| LLVM_INTEGRATE_LABEL = "llvm-integrate" |
| |
| |
| def skip_path(path: str) -> bool: |
| return any(fnmatch.fnmatch(path, pattern) for pattern in SKIP_PATH_PATTERNS) |
| |
| |
| def set_output(d: Mapping[str, str]): |
| print(f"Setting outputs: {d}") |
| step_output_file = os.environ["GITHUB_OUTPUT"] |
| with open(step_output_file, "a") as f: |
| f.writelines(f"{k}={v}" + "\n" for k, v in d.items()) |
| |
| |
| def write_job_summary(summary: str): |
| """Write markdown messages on Github workflow UI. |
| See https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#adding-a-job-summary |
| """ |
| step_summary_file = os.environ["GITHUB_STEP_SUMMARY"] |
| with open(step_summary_file, "a") as f: |
| # Use double newlines to split sections in markdown. |
| f.write(summary + "\n\n") |
| |
| |
| def check_description_and_show_diff(original_description: str, |
| original_labels: Sequence[str], |
| current_description: str, |
| current_labels: Sequence[str]): |
| original_labels = sorted(original_labels) |
| current_labels = sorted(current_labels) |
| if (original_description == current_description and |
| original_labels == current_labels): |
| return |
| |
| description_diffs = difflib.unified_diff( |
| original_description.splitlines(keepends=True), |
| current_description.splitlines(keepends=True)) |
| description_diffs = "".join(description_diffs) |
| |
| if description_diffs != "": |
| description_diffs = textwrap.dedent("""\ |
| ```diff |
| {} |
| ``` |
| """).format(description_diffs) |
| |
| if original_labels == current_labels: |
| label_diffs = "" |
| else: |
| label_diffs = textwrap.dedent("""\ |
| ``` |
| Original labels: {original_labels} |
| Current labels: {current_labels} |
| ``` |
| """).format(original_labels=original_labels, current_labels=current_labels) |
| |
| write_job_summary( |
| textwrap.dedent("""\ |
| :pushpin: Using the PR description and labels different from the original PR event that started this workflow. |
| |
| <details> |
| <summary>Click to show diff (original vs. current)</summary> |
| |
| {description_diffs} |
| |
| {label_diffs} |
| </details>""").format(description_diffs=description_diffs, |
| label_diffs=label_diffs)) |
| |
| |
| def get_trailers_and_labels(is_pr: bool) -> Tuple[Mapping[str, str], List[str]]: |
| if not is_pr: |
| return ({}, []) |
| |
| title = os.environ["PR_TITLE"] |
| body = os.environ.get("PR_BODY", "") |
| labels = json.loads(os.environ.get("PR_LABELS", "[]")) |
| original_title = os.environ.get("ORIGINAL_PR_TITLE") |
| original_body = os.environ.get("ORIGINAL_PR_BODY", "") |
| original_labels = json.loads(os.environ.get("ORIGINAL_PR_LABELS", "[]")) |
| |
| description = PR_DESCRIPTION_TEMPLATE.format(title=title, body=body) |
| |
| # PR information can be fetched from API for the latest updates. If |
| # ORIGINAL_PR_TITLE is set, compare the current and original description and |
| # show a notice if they are different. This is mostly to inform users that the |
| # workflow might not parse the PR description they expect. |
| if original_title is not None: |
| original_description = PR_DESCRIPTION_TEMPLATE.format(title=original_title, |
| body=original_body) |
| print("Original PR description and labels:", |
| original_description, |
| original_labels, |
| sep="\n") |
| check_description_and_show_diff(original_description=original_description, |
| original_labels=original_labels, |
| current_description=description, |
| current_labels=labels) |
| |
| print("Parsing PR description and labels:", description, labels, sep="\n") |
| |
| trailer_lines = subprocess.run( |
| ["git", "interpret-trailers", "--parse", "--no-divider"], |
| input=description, |
| stdout=subprocess.PIPE, |
| check=True, |
| text=True, |
| timeout=60).stdout.splitlines() |
| trailer_map = { |
| k.lower().strip(): v.strip() |
| for k, v in (line.split(":", maxsplit=1) for line in trailer_lines) |
| } |
| 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(base_ref: str) -> bool: |
| return any(not skip_path(p) for p in get_modified_paths(base_ref)) |
| |
| |
| def should_run_ci(is_pr: bool, trailers: Mapping[str, str]) -> bool: |
| if not is_pr: |
| print("Running CI independent of diff because run was not triggered by a" |
| " pull request event.") |
| return True |
| |
| if SKIP_CI_KEY in trailers: |
| print(f"Not running CI because PR description has '{SKIP_CI_KEY}' trailer.") |
| return False |
| |
| base_ref = os.environ["BASE_REF"] |
| try: |
| modifies = modifies_included_path(base_ref) |
| except TimeoutError as e: |
| print("Computing modified files timed out. Running the CI") |
| return True |
| |
| if not modifies: |
| print("Skipping CI because all modified files are marked as excluded.") |
| return False |
| |
| print("CI should run") |
| return True |
| |
| |
| def get_runner_env(trailers: Mapping[str, str]) -> str: |
| runner_env = trailers.get(RUNNER_ENV_KEY) |
| if runner_env is None: |
| print(f"Using '{RUNNER_ENV_DEFAULT}' runners because '{RUNNER_ENV_KEY}'" |
| f" not found in {trailers}") |
| runner_env = RUNNER_ENV_DEFAULT |
| else: |
| print( |
| f"Using runner environment '{runner_env}' from PR description trailers") |
| return runner_env |
| |
| |
| def get_benchmark_presets(trailers: Mapping[str, str], labels: Sequence[str], |
| is_pr: bool, is_llvm_integrate_pr: bool) -> str: |
| """Parses and validates the benchmark presets from trailers. |
| |
| Args: |
| trailers: trailers from PR description. |
| labels: list of PR labels. |
| is_pr: is pull request event. |
| is_llvm_integrate_pr: is LLVM integration PR. |
| |
| Returns: |
| A comma separated preset string, which later will be parsed by |
| build_tools/benchmarks/export_benchmark_config.py. |
| """ |
| |
| skip_llvm_integrate_benchmark = SKIP_LLVM_INTEGRATE_BENCHMARK_KEY in trailers |
| if skip_llvm_integrate_benchmark: |
| print("Skipping default benchmarking on LLVM integration because PR " |
| f"description has '{SKIP_LLVM_INTEGRATE_BENCHMARK_KEY}' trailer.") |
| |
| if not is_pr: |
| preset_options = {DEFAULT_BENCHMARK_PRESET} |
| print(f"Using benchmark presets '{preset_options}' for non-PR run") |
| elif is_llvm_integrate_pr and not skip_llvm_integrate_benchmark: |
| # Run all benchmark presets for LLVM integration PRs. |
| preset_options = {DEFAULT_BENCHMARK_PRESET} |
| print(f"Using benchmark preset '{preset_options}' for LLVM integration PR") |
| else: |
| preset_options = set( |
| label.split(":", maxsplit=1)[1] |
| for label in labels |
| if label.startswith(BENCHMARK_LABEL_PREFIX + ":")) |
| trailer = trailers.get(BENCHMARK_EXTRA_KEY) |
| if trailer is not None: |
| preset_options = preset_options.union( |
| option.strip() for option in trailer.split(",")) |
| print(f"Using benchmark preset '{preset_options}' from trailers and labels") |
| |
| if DEFAULT_BENCHMARK_PRESET in preset_options: |
| preset_options.remove(DEFAULT_BENCHMARK_PRESET) |
| preset_options.update(DEFAULT_BENCHMARK_PRESET_GROUP) |
| |
| if preset_options.intersection(DEFAULT_BENCHMARK_PRESET_GROUP): |
| # The is a sugar to run the compilation benchmarks when any default |
| # benchmark preset is present. |
| preset_options.add("comp-stats") |
| |
| preset_options = sorted(preset_options) |
| for preset_option in preset_options: |
| if preset_option not in BENCHMARK_PRESET_OPTIONS: |
| raise ValueError(f"Unknown benchmark preset option: '{preset_option}'.\n" |
| f"Available options: '{BENCHMARK_PRESET_OPTIONS}'.") |
| |
| return ",".join(preset_options) |
| |
| |
| def main(): |
| is_pr = os.environ["GITHUB_EVENT_NAME"] == "pull_request" |
| trailers, labels = get_trailers_and_labels(is_pr) |
| is_llvm_integrate_pr = bool( |
| LLVM_INTEGRATE_TITLE_PATTERN.search(os.environ.get("PR_TITLE", "")) or |
| LLVM_INTEGRATE_BRANCH_PATTERN.search(os.environ.get("PR_BRANCH", "")) or |
| LLVM_INTEGRATE_LABEL in labels) |
| output = { |
| "should-run": |
| json.dumps(should_run_ci(is_pr, trailers)), |
| "is-pr": |
| json.dumps(is_pr), |
| "runner-env": |
| get_runner_env(trailers), |
| "runner-group": |
| "presubmit" if is_pr else "postsubmit", |
| "write-caches": |
| "0" if is_pr else "1", |
| "benchmark-presets": |
| get_benchmark_presets(trailers, labels, is_pr, is_llvm_integrate_pr), |
| } |
| |
| set_output(output) |
| |
| |
| if __name__ == "__main__": |
| main() |