| #!/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_REPOSITORY: GitHub org and repository, e.g. iree-org/iree. | |
| - GITHUB_WORKFLOW_REF: GitHub workflow ref, e.g. | |
| iree-org/iree/.github/workflows/ci.yml@refs/pull/1/merge. | |
| - 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 enum | |
| import fnmatch | |
| import json | |
| import os | |
| import re | |
| import pathlib | |
| import string | |
| import subprocess | |
| import sys | |
| import textwrap | |
| from typing import Iterable, List, Mapping, Optional, Sequence, Set, Tuple | |
| import yaml | |
| # Add build_tools python dir to the search path. | |
| sys.path.insert(0, str(pathlib.Path(__file__).parent.with_name("python"))) | |
| # We don't get StrEnum till Python 3.11 | |
| @enum.unique | |
| class Trailer(str, enum.Enum): | |
| __str__ = str.__str__ | |
| SKIP_CI = "skip-ci" | |
| SKIP_JOBS = "ci-skip" | |
| EXTRA_JOBS = "ci-extra" | |
| EXACTLY_JOBS = "ci-exactly" | |
| RUNNER_ENV = "runner-env" | |
| # Before Python 3.12, it the native __contains__ doesn't work for checking | |
| # member values like this and it's not possible to easily override this. | |
| # https://docs.python.org/3/library/enum.html#enum.EnumType.__contains__ | |
| @classmethod | |
| def contains(cls, val): | |
| try: | |
| cls(val) | |
| except ValueError: | |
| return False | |
| return True | |
| # This is to help prevent typos. For now we hard error on any trailer that | |
| # starts with this prefix but isn't in our list. We can add known commonly used | |
| # trailers to our list or we might consider relaxing this. | |
| RESERVED_TRAILER_PREFIXES = ["ci-", "bewnchmark-", "skip-"] | |
| ALL_KEY = "all" | |
| # 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/*", | |
| # These configure the runners themselves and don't affect presubmit. | |
| "build_tools/github_actions/runner/*", | |
| ".github/ISSUE_TEMPLATE/*", | |
| "*.cff", | |
| "*.clang-format", | |
| "*.gitignore", | |
| "*.git-blame-ignore-revs", | |
| "*.md", | |
| "*.natvis", | |
| "*.pylintrc", | |
| "*.rst", | |
| "*.toml", | |
| "*.yamllint.yml", | |
| "*.yapf", | |
| "*CODEOWNERS", | |
| "*AUTHORS", | |
| "*LICENSE", | |
| ] | |
| RUNNER_ENV_DEFAULT = "prod" | |
| RUNNER_ENV_OPTIONS = [RUNNER_ENV_DEFAULT, "testing"] | |
| CONTROL_JOBS = frozenset(["setup", "summary"]) | |
| # Jobs to run only on postsubmit by default. | |
| # They may also run on presubmit only under certain conditions. | |
| DEFAULT_POSTSUBMIT_ONLY_JOBS = frozenset( | |
| [ | |
| # None. | |
| ] | |
| ) | |
| # 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. Sample: | |
| # ("test_nvidia_a100", ["compiler/plugins/target/CUDA/*"]), | |
| # Note: these jobs should also be included in DEFAULT_POSTSUBMIT_ONLY_JOBS. | |
| PRESUBMIT_TOUCH_ONLY_JOBS = [ | |
| # None. | |
| ] | |
| PR_DESCRIPTION_TEMPLATE = string.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/iree-org/iree/issues/12268 | |
| LLVM_INTEGRATE_TITLE_PATTERN = re.compile("^integrate|bump.+llvm", re.IGNORECASE) | |
| LLVM_INTEGRATE_BRANCH_PATTERN = re.compile( | |
| "integrates/llvm|bump-llvm|llvm-bump|integrate-llvm", 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.substitute(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.substitute( | |
| 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) | |
| } | |
| for key in trailer_map: | |
| if not Trailer.contains(key): | |
| for prefix in RESERVED_TRAILER_PREFIXES: | |
| if key.startswith(prefix): | |
| print( | |
| f"Trailer '{key}' starts with reserved prefix" | |
| f"'{prefix}' but is unknown." | |
| ) | |
| print(f"Skipping unknown trailer '{key}'", file=sys.stderr) | |
| return (trailer_map, labels) | |
| def get_modified_paths(base_ref: str) -> Optional[Iterable[str]]: | |
| """Returns the paths of modified files in this code change.""" | |
| try: | |
| 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: | |
| runner_env = trailers.get(Trailer.RUNNER_ENV) | |
| if runner_env is None: | |
| print( | |
| f"Using '{RUNNER_ENV_DEFAULT}' runners because" | |
| f" '{Trailer.RUNNER_ENV}' 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 parse_jobs_trailer( | |
| trailers: Mapping[str, str], key: str, all_jobs: Set[str] | |
| ) -> Set[str]: | |
| jobs_text = trailers.get(key) | |
| if jobs_text is None: | |
| return set() | |
| jobs = set(name.strip() for name in jobs_text.split(",")) | |
| if ALL_KEY in jobs: | |
| if len(jobs) != 1: | |
| raise ValueError( | |
| f"'{ALL_KEY}' must be alone in job specification" | |
| f" trailer, but got '{key}: {jobs_text}'" | |
| ) | |
| print(f"Expanded trailer '{key}: {jobs_text}' to all jobs") | |
| return all_jobs | |
| jobs = set(jobs) | |
| unknown_jobs = jobs - all_jobs | |
| if unknown_jobs: | |
| # Unknown jobs may be for a different workflow. Warn then continue. | |
| print(f"::warning::Unknown jobs '{','.join(unknown_jobs)}' in trailer '{key}'") | |
| jobs = jobs - unknown_jobs | |
| return jobs | |
| def parse_path_from_workflow_ref(repo: str, workflow_ref: str) -> pathlib.Path: | |
| if not workflow_ref.startswith(repo): | |
| raise ValueError( | |
| "Can't parse the external workflow ref" | |
| f" '{workflow_ref}' outside the repo '{repo}'." | |
| ) | |
| # The format of workflow ref: `${repo}/${workflow file path}@${ref}` | |
| workflow_file = workflow_ref[len(repo) :].lstrip("/") | |
| workflow_file = workflow_file.split("@", maxsplit=1)[0] | |
| return pathlib.Path(workflow_file) | |
| def parse_jobs_from_workflow_file(workflow_file: pathlib.Path) -> Set[str]: | |
| print(f"Parsing workflow file: '{workflow_file}'.") | |
| workflow = yaml.load(workflow_file.read_text(), Loader=yaml.SafeLoader) | |
| all_jobs = set(workflow["jobs"].keys()) | |
| all_jobs -= CONTROL_JOBS | |
| if ALL_KEY in all_jobs: | |
| raise ValueError(f"Workflow has job with reserved name '{ALL_KEY}'") | |
| return all_jobs | |
| def get_enabled_jobs( | |
| trailers: Mapping[str, str], | |
| all_jobs: Set[str], | |
| *, | |
| is_pr: bool, | |
| is_llvm_integrate_pr: 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. | |
| is_llvm_integrate_pr: whether this is for an LLVM integrate PR 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 event.", | |
| file=sys.stderr, | |
| ) | |
| return all_jobs | |
| if is_llvm_integrate_pr: | |
| print( | |
| "Running all jobs because run was triggered by an LLVM integrate pull request event.", | |
| file=sys.stderr, | |
| ) | |
| return all_jobs | |
| if Trailer.SKIP_CI in trailers: | |
| if ( | |
| Trailer.EXACTLY_JOBS in trailers | |
| or Trailer.EXTRA_JOBS in trailers | |
| or Trailer.SKIP_JOBS in trailers | |
| ): | |
| raise ValueError( | |
| f"Cannot specify both '{Trailer.SKIP_JOBS}' and any of" | |
| f" '{Trailer.EXACTLY_JOBS}', '{Trailer.EXTRA_JOBS}'," | |
| f" '{Trailer.SKIP_JOBS}'" | |
| ) | |
| print( | |
| f"Skipping all jobs because PR description has" | |
| f" '{Trailer.SKIP_CI}' trailer." | |
| ) | |
| return set() | |
| if Trailer.EXACTLY_JOBS in trailers: | |
| if Trailer.EXTRA_JOBS in trailers or Trailer.SKIP_JOBS in trailers: | |
| raise ValueError( | |
| f"Cannot mix trailer '{Trailer.EXACTLY_JOBS}' with" | |
| f" '{Trailer.EXTRA_JOBS}' or '{Trailer.SKIP_JOBS}'" | |
| ) | |
| exactly_jobs = parse_jobs_trailer( | |
| trailers, | |
| Trailer.EXACTLY_JOBS, | |
| all_jobs, | |
| ) | |
| return exactly_jobs | |
| skip_jobs = parse_jobs_trailer(trailers, Trailer.SKIP_JOBS, all_jobs) | |
| extra_jobs = parse_jobs_trailer(trailers, Trailer.EXTRA_JOBS, all_jobs) | |
| ambiguous_jobs = skip_jobs & extra_jobs | |
| if ambiguous_jobs: | |
| raise ValueError( | |
| f"Jobs cannot be specified in both '{Trailer.SKIP_JOBS}' and" | |
| f" '{Trailer.EXTRA_JOBS}', but found {ambiguous_jobs}" | |
| ) | |
| enabled_jobs = all_jobs - DEFAULT_POSTSUBMIT_ONLY_JOBS | |
| if not modifies_non_skip_paths(modified_paths): | |
| print( | |
| "Not including any jobs by default because all modified files" | |
| " are marked as excluded." | |
| ) | |
| enabled_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): | |
| print( | |
| f"Enabling '{job}' since '{modified_path}' matches pattern '{match_path}'" | |
| ) | |
| enabled_jobs |= {job} | |
| return (enabled_jobs | extra_jobs) - skip_jobs | |
| 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 | |
| ) | |
| 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"] | |
| try: | |
| all_jobs = parse_jobs_from_workflow_file(workflow_file) | |
| enabled_jobs = get_enabled_jobs( | |
| trailers, | |
| all_jobs, | |
| modified_paths=get_modified_paths(base_ref), | |
| is_pr=is_pr, | |
| is_llvm_integrate_pr=is_llvm_integrate_pr, | |
| ) | |
| except ValueError as e: | |
| print(e) | |
| sys.exit(1) | |
| output = { | |
| "enabled-jobs": json.dumps(sorted(enabled_jobs)), | |
| "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", | |
| } | |
| set_output(output) | |
| if __name__ == "__main__": | |
| main() |