Skip to content
Snippets Groups Projects
build_root.py 9.71 KiB
Newer Older
olemorud's avatar
olemorud committed
#!/usr/bin/env python3

# pylint: disable=broad-except,missing-function-docstring,line-too-long

"""This mainly functions as a shell script, but python is used for its
   superior control flow. An important requirement of the CI is easily
   reproducible builds, therefore a wrapper is made for running shell
   commands so that they are also logged.

   The log is printed when build fails/succeeds and needs to perfectly
   reproduce the build when pasted into a shell. Therefore all file system
   modifying code not executed from shell needs a shell equivalent
   explicitly appended to the shell log.
      e.g. `os.chdir(x)` requires `cd x` to be appended to the shell log  """

olemorud's avatar
olemorud committed
import datetime
import os
import shutil
import tarfile
from hashlib import sha1

olemorud's avatar
olemorud committed
import openstack
from build_utils import (
    cmake_options_from_dict,
    die,
    download_latest,
    github_log_group,
    print_info,
    load_config,
    print_shell_log,
    subprocess_with_log,
    upload_file,
    print_warning,
S3CONTAINER = 'ROOT-build-artifacts'  # Used for uploads
S3URL = 'https://s3.cern.ch/swift/v1/' + S3CONTAINER  # Used for downloads

try:
    CONNECTION = openstack.connect(cloud='envvars')
except:
    CONNECTION = None
WINDOWS = (os.name == 'nt')
WORKDIR = '/tmp/workspace' if not WINDOWS else 'C:/ROOT-CI'
COMPRESSIONLEVEL = 6 if not WINDOWS else 1
olemorud's avatar
olemorud committed

def main():
    # openstack.enable_logging(debug=True)

    # accumulates commands executed so they can be displayed as a script on build failure
    shell_log = ""

    # used when uploading artifacts, calculate early since build times are inconsistent
olemorud's avatar
olemorud committed
    yyyy_mm_dd = datetime.datetime.today().strftime('%Y-%m-%d')

    # it is difficult to use boolean flags from github actions, use strings to convey
    # true/false for boolean arguments instead.
olemorud's avatar
olemorud committed
    parser = argparse.ArgumentParser()
    parser.add_argument("--platform",     default="centos8", help="Platform to build on")
    parser.add_argument("--incremental",  default="false",   help="Do incremental build")
    parser.add_argument("--buildtype",    default="Release", help="Release|Debug|RelWithDebInfo")
    parser.add_argument("--base_ref",     default=None,      help="Ref to target branch")
    parser.add_argument("--head_ref",     default=None,      help="Ref to feature branch")
    parser.add_argument("--architecture", default=None,      help="Windows only, target arch")
    parser.add_argument("--repository",   default="https://github.com/root-project/root.git",
olemorud's avatar
olemorud committed
                        help="url to repository")

    args = parser.parse_args()

    args.incremental = args.incremental.lower() in ('yes', 'true', '1', 'on')
    if not args.base_ref:
        die(os.EX_USAGE, "base_ref not specified")
    pull_request = args.head_ref and args.head_ref != args.base_ref
    if not pull_request:
        print_info("head_ref same as base_ref, assuming non-PR build")
    shell_log = cleanup_previous_build(shell_log)
    # Load CMake options from .github/workflows/root-ci-config/buildconfig/[platform].txt
    this_script_dir = os.path.dirname(os.path.abspath(__file__))
olemorud's avatar
olemorud committed

    options_dict = {
        **load_config(f'{this_script_dir}/buildconfig/global.txt'),
        # file below overwrites values from above
        **load_config(f'{this_script_dir}/buildconfig/{args.platform}.txt')
olemorud's avatar
olemorud committed
    }
olemorud's avatar
olemorud committed
    options = cmake_options_from_dict(options_dict)

olemorud's avatar
olemorud committed
        options = "-Thost=x64 " + options

        if args.architecture == 'x86':
            options = "-AWin32 " + options
    # The sha1 of the build option string is used to find existing artifacts
    # with matching build options on s3 storage.
    option_hash = sha1(options.encode('utf-8')).hexdigest()
    obj_prefix = f'{args.platform}/{args.base_ref}/{args.buildtype}/{option_hash}'
    # Make testing of CI in forks not impact artifacts
    if 'root-project/root' not in args.repository:
        obj_prefix = f"ci-testing/{args.repository.split('/')[-2]}/" + obj_prefix
        print("Attempting to download")
    if args.incremental:
olemorud's avatar
olemorud committed
        try:
            shell_log += download_artifacts(obj_prefix, shell_log)
        except Exception as err:
            print_warning(f'Failed to download: {err}')
            args.incremental = False
    shell_log = git_pull(args.repository, args.base_ref, shell_log)
    if pull_request:
        shell_log = rebase(args.base_ref, args.head_ref, shell_log)
    shell_log = build(options, args.buildtype, shell_log)
    testing: bool = options_dict['testing'].lower() == "on" and options_dict['roottest'].lower() == "on"
    if testing:
        extra_ctest_flags = ""
        
        if WINDOWS:
olemorud's avatar
olemorud committed
            extra_ctest_flags += "--repeat until-pass:3 "
            extra_ctest_flags += "--build-config " + args.buildtype

        shell_log = run_ctest(shell_log, extra_ctest_flags)
    if CONNECTION:
        archive_and_upload(yyyy_mm_dd, obj_prefix)

    print_shell_log(shell_log)
@github_log_group("Clean up from previous runs")
def cleanup_previous_build(shell_log):
    # runners should never have root permissions but be on the safe side
    if WORKDIR == "" or WORKDIR == "/":
        die(1, "WORKDIR not set", "")
    if WINDOWS:
        # windows
        os.environ['COMSPEC'] = 'powershell.exe'
olemorud's avatar
olemorud committed
        result, shell_log = subprocess_with_log(f"""
            $ErrorActionPreference = 'Stop'
            if (Test-Path {WORKDIR}) {{
                Remove-Item -Recurse -Force -Path {WORKDIR}
            }}
            New-Item -Force -Type directory -Path {WORKDIR}
olemorud's avatar
olemorud committed
        """, shell_log)
        # mac/linux/POSIX
        result, shell_log = subprocess_with_log(f"""
            rm -rf {WORKDIR}
            mkdir -p {WORKDIR}
        die(result, "Failed to clean up previous artifacts", shell_log)
    
    return shell_log
@github_log_group("Pull/clone branch")
def git_pull(repository:str, branch: str, shell_log: str):
    returncode = 1

    for attempts in range(5):
        if returncode == 0:
            break

        if os.path.exists(f"{WORKDIR}/src/.git"):
            returncode, shell_log = subprocess_with_log(f"""
                cd '{WORKDIR}/src'
                git checkout {branch}
                git fetch
                git reset --hard @{{u}}
            """, shell_log)
        else:
            returncode, shell_log = subprocess_with_log(f"""
                git clone --branch {branch} --single-branch {repository} "{WORKDIR}/src"
            """, shell_log)

    if returncode != 0:
        die(returncode, f"Failed to pull {branch}", shell_log)
    
    return shell_log
@github_log_group("Download previous build artifacts")
def download_artifacts(obj_prefix: str, shell_log: str):
    try:
        tar_path, shell_log = download_latest(S3URL, obj_prefix, WORKDIR, shell_log)
olemorud's avatar
olemorud committed

        print(f"\nExtracting archive {tar_path}")
        with tarfile.open(tar_path) as tar:
            tar.extractall(WORKDIR)
        shell_log += f'\ntar -xf {tar_path}\n'

    except Exception as err:
        print_warning("failed to download/extract:", err)
        shutil.rmtree(f'{WORKDIR}/src', ignore_errors=True)
        shutil.rmtree(f'{WORKDIR}/build', ignore_errors=True)
        raise err
    
    return shell_log
@github_log_group("Run tests")
def run_ctest(shell_log: str, extra_ctest_flags: str) -> str:
olemorud's avatar
olemorud committed
    result, shell_log = subprocess_with_log(f"""
        cd '{WORKDIR}/build'
olemorud's avatar
olemorud committed
        ctest --parallel {os.cpu_count()} --output-junit TestResults.xml {extra_ctest_flags}
olemorud's avatar
olemorud committed
    """, shell_log)
    
    if result != 0:
        print_warning("Some tests failed")
olemorud's avatar
olemorud committed
    
    return shell_log


@github_log_group("Archive and upload")
def archive_and_upload(archive_name, prefix):
olemorud's avatar
olemorud committed
    new_archive = f"{archive_name}.tar.gz"

    os.chdir(WORKDIR)

    with tarfile.open(f"{WORKDIR}/{new_archive}", "x:gz", compresslevel=COMPRESSIONLEVEL) as targz:
olemorud's avatar
olemorud committed
        targz.add("src")
        targz.add("build")

    upload_file(
        connection=CONNECTION,
olemorud's avatar
olemorud committed
        container=S3CONTAINER,
        dest_object=f"{prefix}/{new_archive}",
        src_file=f"{WORKDIR}/{new_archive}"
@github_log_group("Build")
def build(options, buildtype, shell_log):
    generator_flags = "-- '-verbosity:minimal'" if WINDOWS else ""

    if not os.path.isdir(f'{WORKDIR}/build'):
        result, shell_log = subprocess_with_log(f"mkdir {WORKDIR}/build", shell_log)

        if result != 0:
            die(result, "Failed to create build directory", shell_log)

    if not os.path.exists(f'{WORKDIR}/build/CMakeCache.txt'):
olemorud's avatar
olemorud committed
        result, shell_log = subprocess_with_log(f"""
            cmake -S '{WORKDIR}/src' -B '{WORKDIR}/build' {options} -DCMAKE_BUILD_TYPE={buildtype}
olemorud's avatar
olemorud committed
        """, shell_log)

        if result != 0:
            die(result, "Failed cmake generation step", shell_log)

    result, shell_log = subprocess_with_log(f"""
        cmake --build '{WORKDIR}/build' --config '{buildtype}' --parallel '{os.cpu_count()}' {generator_flags}
olemorud's avatar
olemorud committed
    """, shell_log)

    if result != 0:
        die(result, "Failed to build", shell_log)
    
olemorud's avatar
olemorud committed
    return shell_log


@github_log_group("Rebase")
def rebase(base_ref, head_ref, shell_log) -> str:
    # This mental gymnastics is neccessary because the the CMake build fetches 
    # roottest based on the current branch name of ROOT
    #
    # rebase fails unless user.email and user.name is set
olemorud's avatar
olemorud committed
    result, shell_log = subprocess_with_log(f"""
        cd '{WORKDIR}/src'
olemorud's avatar
olemorud committed
        git config user.email "rootci@root.cern"
        git config user.name 'ROOT Continous Integration'
        
        git fetch origin {head_ref}:__tmp
        git checkout __tmp
        git rebase {base_ref}
        git checkout {base_ref}
        git reset --hard __tmp
olemorud's avatar
olemorud committed
    """, shell_log)

    if result != 0:
        die(result, "Rebase failed", shell_log)

    return shell_log


if __name__ == "__main__":
    main()