diff --git a/dist/tools/ci/README.md b/dist/tools/ci/README.md index 7236afe395..7394f8df81 100644 --- a/dist/tools/ci/README.md +++ b/dist/tools/ci/README.md @@ -86,6 +86,49 @@ finish annotations. to attach the actual annotations to your PR. You don't need to call it from within your test if you are adding that test to [static_tests.sh]. +Checking if Fast CI Runs are Sufficient +--------------------------------------- + +The script `can_fast_ci_run.py` checks if a given change set a PR contains +justifies a full CI run, or whether only building certain apps or all apps for +certain boards is sufficient and which those are. The script will return with +exit code 0 if a fast CI run is sufficient and yield a JSON containing the +apps which need to be rebuild (for all boards) and the list of boards for which +all apps need to be rebuild. + +### Usage + +1. Pull the current upstream state into a branch (default: `master`) +2. Create a temporary branch that contains the PR either rebased on top of the + upstream state or merged into the upstream state +3. Check out the branch containing the state of upstream + your PR +4. Run `./dist/tools/ci/can_fast_ci_run.py` + +#### Options + +- If the script is not launched in the RIOT repository root, provide a path + to the repo root via `--riotbase` parameter +- If the upstream state is not in `master`, the `--upstreambranch` parameter + can be used to specify it (or a commit of the current upstream state) +- If the script opts for a full rebuild, the passing `--explain` will result + in the script explaining its reasoning +- To inspect the classification of changed files, the `--debug` switch will + print it out + +#### Gotchas + +- If the script is not launched in a branch that contains all changes of the + upstream branch, the diff set will be too large. +- The script relies on the presence of a `Makefile` to detect the path of + modules. If changed files have no parent directory containing a `Makefile` + (e.g. because a module was deleted), the classification will fail. This + results in a full CI run, but this is the desired behavior anyway. +- Right now, any change in a module that is not a board, or in any package, will + result in a full CI run. Maybe the KConfig migration will make it easier to + get efficiently get a full list of applications depending on any given module, + so that fast CI runs can also be performed when modules and/or packages are + changed. + [static_tests.sh]: ./static_tests.sh [Github annotations]: https://github.blog/2018-12-14-introducing-check-runs-and-annotations/ [github_annotate.sh]: ./github_annotate.sh diff --git a/dist/tools/ci/can_fast_ci_run.py b/dist/tools/ci/can_fast_ci_run.py new file mode 100755 index 0000000000..c5db0344d9 --- /dev/null +++ b/dist/tools/ci/can_fast_ci_run.py @@ -0,0 +1,225 @@ +#!/usr/bin/python3 +""" +Command line utility to check if only a subset of board / application combinations +need to be build in the CI +""" +import argparse +import io +import json +import os +import re +import subprocess +import sys +from functools import partial + +REGEX_GIT_DIFF_RENAME_COMPLEX = re.compile(r"^[0-9-]+\s+[0-9-]+\s+(.*)\{(.*) => (.*)\}(.*)$") +REGEX_GIT_DIFF_RENAME_SIMPLE = re.compile(r"^[0-9-]+\s+[0-9-]+\s+(.*) => (.*)$") +REGEX_GIT_DIFF_SINGLE_FILE = re.compile(r"^[0-9-]+\s+[0-9-]+\s+(.*)$") + +# Beware: Order matters. The first matching rule will be applied +OTHER_CLASSIFIERS = [ + [re.compile(r"^(Makefile.*|sys\/Makefile.*|drivers\/Makefile.*|makefiles\/.*)$"), "build-system"], + [re.compile(r"^(drivers\/include\/.*|sys\/include\/.*)$"), "public-headers"], + [re.compile(r"^(Kconfig|kconfigs\/.*|pkg\/Kconfig|sys\/Kconfig|drivers\/Kconfig)$"), "kconfig"], + [re.compile(r"^(.*\.cff|doc\/.*|.*\.md|.*\.txt)$"), "doc"], + [re.compile(r"^(CODEOWNERS|.mailmap|.gitignore|.github\/.*)$"), "git"], + [re.compile(r"^(\.murdock|dist\/ls\/.*|\.drone.yml)$"), "ci-murdock"], + [re.compile(r"^(\.bandit|\.circleci\/.*|\.drone.yml)$"), "ci-other"], + [re.compile(r"^(dist\/.*|Vagrantfile)$"), "tools"], +] + +REGEX_MODULE = re.compile(r"^(boards\/common|core|cpu|drivers|sys)\/") +REGEX_PKG = re.compile(r"^pkg\/") +REGEX_BOARD = re.compile(r"^boards\/") +REGEX_APP = re.compile(r"^(bootloaders|examples|fuzzing|tests)\/") + +EXCEPTION_MODULES = {"boards/common/nrf52"} + +print_err = partial(print, file=sys.stderr) + + +def print_change_set_section(name, contents): + """ + Print the given change set section human reable + """ + if not contents: + return + print_err(name) + print_err("=" * len(name)) + print_err("") + for category in sorted(contents): + print_err(category) + print_err("-" * len(category)) + print_err("") + for file in sorted(contents[category]): + print_err("- {}".format(file)) + print_err("") + + +class ChangeSet: + """ + Representation of the modules affected by a change set + """ + def __init__(self, riotbase=os.getcwd()): + self.apps = {} + self.boards = {} + self.modules = {} + self.other = {} + self.pkgs = {} + self._riotbase = os.path.normpath(riotbase) + + def __add_module(self, dest, file): + module = os.path.dirname(file) + while module != "": + makefile = os.path.join(self._riotbase, module, "Makefile") + if os.path.isfile(makefile) or module in EXCEPTION_MODULES: + if module in dest: + dest[module].append(file) + else: + dest[module] = [file] + return + module = os.path.dirname(module) + raise Exception("Module containing file \"{}\" not found".format(file)) + + def add_file(self, file): + """ + Add the given file to the change set + """ + # normalize path + file = os.path.normpath(file) + if file.startswith('./'): + file = file[2:] + # turn path into path relative to riotbase, if needed + if file.startswith(self._riotbase): + file = file[len(self._riotbase):] + + for regex, name in OTHER_CLASSIFIERS: + if regex.match(file): + if name in self.other: + self.other[name].append(file) + else: + self.other[name] = [file] + return + + if REGEX_MODULE.match(file): + self.__add_module(self.modules, file) + elif REGEX_PKG.match(file): + self.__add_module(self.pkgs, file) + elif REGEX_BOARD.match(file): + self.__add_module(self.boards, file) + elif REGEX_APP.match(file): + self.__add_module(self.apps, file) + else: + raise Exception("File \"{}\" doesn't match any known category".format(file)) + + def print_files_and_classifications(self): + """ + Print all files and their classification in human readable format + """ + print_change_set_section("Other", self.other) + print_change_set_section("Modules", self.modules) + print_change_set_section("Packages", self.pkgs) + print_change_set_section("Boards", self.boards) + print_change_set_section("Apps", self.apps) + + +def classify_changes(riotbase=None, upstream_branch="master"): + """ + Runs the given compiler with -v -E on an no-op compilation unit and parses the built-in + include search directories and the GCC version from the output + + :param args: parse command line arguments + :type args: dict + :param pr_branch: name of the PR branch + :type pr_branch: str + :param upstream_branch: name of the main upstream branch the PR should be merged into + :type upstream_branch: str + + :return: True if fast rebuilt is possible, False otherwise + :rtype: bool + """ + change_set = ChangeSet(riotbase) + + with subprocess.Popen(["git", "diff", "--numstat", "HEAD..{}".format(upstream_branch)], + stdout=subprocess.PIPE) as proc: + for line in io.TextIOWrapper(proc.stdout, encoding="utf-8"): + match = REGEX_GIT_DIFF_RENAME_COMPLEX.match(line) + if match: + prefix = match.group(1) + suffix = match.group(4) + file_before = prefix + match.group(2) + suffix + file_after = prefix + match.group(3) + suffix + change_set.add_file(file_before) + change_set.add_file(file_after) + continue + + match = REGEX_GIT_DIFF_RENAME_SIMPLE.match(line) + if match: + file_before = match.group(1) + file_after = match.group(2) + change_set.add_file(file_before) + change_set.add_file(file_after) + continue + + match = REGEX_GIT_DIFF_SINGLE_FILE.match(line) + if match: + file = match.group(1) + change_set.add_file(file) + continue + + raise Exception("Failed to parse \"{}\"".format(line)) + + return change_set + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Check if a fast CI run is possible and which " + + "boards / applications to build") + parser.add_argument("--explain", action="store_true", + help="Explain the reasoning of the decision") + parser.add_argument("--debug", default=False, action="store_const", const=True, + help="Show detailed list of classifications") + parser.add_argument("--riotbase", default=os.getcwd(), + help="Use given paths as RIOT's base path (instead of pwd)") + parser.add_argument("--upstreambranch", default="master", + help="The branch / commit containing the upstream state") + args = parser.parse_args() + try: + change_set = classify_changes(riotbase=args.riotbase, upstream_branch=args.upstreambranch) + except Exception as e: + print_err("Couldn't classify changes: {}".format(e)) + sys.exit(1) + + if args.debug: + change_set.print_files_and_classifications() + + if "kconfig" in change_set.other or "build-system" in change_set.other: + if args.explain: + print_err("General build system / KConfig changes require a full CI run") + sys.exit(1) + + if "ci-murdock" in change_set.other: + if args.explain: + print_err("Murdock related changes require a full CI run") + sys.exit(1) + + if "public-headers" in change_set.other: + if args.explain: + print_err("Changes in public headers require a full CI run") + sys.exit(1) + + if len(change_set.modules) > 0: + if args.explain: + print_err("Currently changing modules require a full CI run") + sys.exit(1) + + if len(change_set.pkgs) > 0: + if args.explain: + print_err("Currently changing packages require a full CI run") + sys.exit(1) + + result = { + "apps": sorted(change_set.apps.keys()), + "boards": sorted(change_set.boards.keys()) + } + sys.stdout.write(json.dumps(result, indent=2))