diff --git a/tools/ci_build/github/apple/build_apple_framework.py b/tools/ci_build/github/apple/build_apple_framework.py index 5a3b242c2a389..9b9f7f6cf234f 100644 --- a/tools/ci_build/github/apple/build_apple_framework.py +++ b/tools/ci_build/github/apple/build_apple_framework.py @@ -2,144 +2,202 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from __future__ import annotations + import argparse -import glob import json import os import pathlib import shutil import subprocess import sys +from dataclasses import dataclass -SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__)) -REPO_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "..", "..", "..")) -BUILD_PY = os.path.join(REPO_DIR, "tools", "ci_build", "build.py") +from build_settings_utils import get_build_params, get_sysroot_arch_pairs, parse_build_settings_file -# We by default will build below 3 archs -DEFAULT_BUILD_OSX_ARCHS = { - "iphoneos": ["arm64"], - "iphonesimulator": ["arm64", "x86_64"], -} +SCRIPT_DIR = pathlib.Path(__file__).parent.resolve() +REPO_DIR = SCRIPT_DIR.parents[3] +BUILD_PY = REPO_DIR / "tools" / "ci_build" / "build.py" -def _parse_build_settings(args): - with open(args.build_settings_file.resolve()) as f: - build_settings_data = json.load(f) +def _filter_sysroot_arch_pairs( + all_sysroot_arch_pairs: list[tuple[str, str]], + args, +) -> list[tuple[str, str]]: + if args.only_build_single_sysroot_arch_framework is not None: + specified_sysroot_arch_pair = ( + args.only_build_single_sysroot_arch_framework[0], + args.only_build_single_sysroot_arch_framework[1], + ) + if specified_sysroot_arch_pair not in all_sysroot_arch_pairs: + raise ValueError( + "Sysroot/arch pair is not present in build settings file. " + f"Specified: {specified_sysroot_arch_pair}, available: {all_sysroot_arch_pairs}" + ) - build_settings = {} + return [specified_sysroot_arch_pair] - build_settings["build_osx_archs"] = build_settings_data.get("build_osx_archs", DEFAULT_BUILD_OSX_ARCHS) + return all_sysroot_arch_pairs.copy() - if "build_params" in build_settings_data: - build_settings["build_params"] = build_settings_data["build_params"] - else: - raise ValueError("build_params is required in the build config file") - return build_settings +# info related to a framework for a single sysroot and arch (e.g., iphoneos/arm64) +@dataclass +class SysrootArchFrameworkInfo: + framework_dir: pathlib.Path + framework_info_file: pathlib.Path + info_plist_file: pathlib.Path -# Build fat framework for all archs of a single sysroot -# For example, arm64 and x86_64 for iphonesimulator -def _build_for_apple_sysroot( - build_config, intermediates_dir, base_build_command, sysroot, archs, build_dynamic_framework -): - # paths of the onnxruntime libraries for different archs - ort_libs = [] - info_plist_path = "" +# find or build the sysroot/arch framework +# if `base_build_command` is not None, the framework will be built +def _find_or_build_sysroot_arch_framework( + build_config: str, + intermediates_dir: pathlib.Path, + base_build_command: list[str] | None, + sysroot: str, + arch: str, + build_dynamic_framework: bool, +) -> SysrootArchFrameworkInfo: + do_build = base_build_command is not None + + build_dir_current_arch = intermediates_dir / f"{sysroot}_{arch}" - # Build binary for each arch, one by one - for current_arch in archs: - build_dir_current_arch = os.path.join(intermediates_dir, sysroot + "_" + current_arch) + if do_build: # Use MacOS SDK for Catalyst builds apple_sysroot = "macosx" if sysroot == "macabi" else sysroot build_command = [ *base_build_command, - "--apple_sysroot=" + apple_sysroot, - "--osx_arch=" + current_arch, - "--build_dir=" + build_dir_current_arch, + f"--apple_sysroot={apple_sysroot}", + f"--osx_arch={arch}", + f"--build_dir={build_dir_current_arch}", ] - # the actual build process for current arch - subprocess.run(build_command, shell=False, check=True, cwd=REPO_DIR) - - # get the compiled lib path - framework_dir = os.path.join( - build_dir_current_arch, - build_config, - build_config + "-" + sysroot, - ( - "onnxruntime.framework" - if build_dynamic_framework - else os.path.join("static_framework", "onnxruntime.framework") - ), - ) - ort_libs.append(os.path.join(framework_dir, "onnxruntime")) + # build framework for specified sysroot/arch + subprocess.run(build_command, shell=False, check=True) + + # get the compiled lib path + framework_dir = pathlib.Path.joinpath( + build_dir_current_arch, + build_config, + build_config + "-" + sysroot, + ( + "onnxruntime.framework" + if build_dynamic_framework + else pathlib.Path("static_framework/onnxruntime.framework") + ), + ) + + info_plist_file = build_dir_current_arch / build_config / "Info.plist" + framework_info_file = build_dir_current_arch / build_config / "framework_info.json" + + if not do_build: + for expected_path in [framework_dir, info_plist_file, framework_info_file]: + if not expected_path.exists(): + raise FileNotFoundError(f"Expected framework path does not exist: {expected_path}") + + return SysrootArchFrameworkInfo( + framework_dir=framework_dir, + info_plist_file=info_plist_file, + framework_info_file=framework_info_file, + ) + + +def _write_sysroot_arch_framework_build_outputs_to_file( + build_dir: pathlib.Path, + built_sysroot_arch_framework_infos: list[SysrootArchFrameworkInfo], + build_outputs_file_path: pathlib.Path, +): + with open(build_outputs_file_path, mode="w") as build_outputs_file: + + def write_path(p: pathlib.Path): + print(p.resolve().relative_to(build_dir), file=build_outputs_file) + + for info in built_sysroot_arch_framework_infos: + write_path(info.framework_dir) + write_path(info.framework_info_file) + write_path(info.info_plist_file) + - # We only need to copy Info.plist, framework_info.json, and headers once since they are the same - if not info_plist_path: - info_plist_path = os.path.join(build_dir_current_arch, build_config, "Info.plist") - framework_info_path = os.path.join(build_dir_current_arch, build_config, "framework_info.json") - headers = glob.glob(os.path.join(framework_dir, "Headers", "*.h")) +# info related to a fat framework for a single sysroot (e.g., iphoneos) +# a fat framework contains one or more frameworks for individual archs (e.g., arm64) +@dataclass +class SysrootFrameworkInfo: + framework_dir: pathlib.Path + framework_info_file: pathlib.Path + + +# Assemble fat framework for all archs of a single sysroot +# For example, arm64 and x86_64 for iphonesimulator +def _assemble_fat_framework_for_sysroot( + intermediates_dir: pathlib.Path, sysroot: str, sysroot_arch_framework_infos: list[SysrootArchFrameworkInfo] +) -> SysrootFrameworkInfo: + assert len(sysroot_arch_framework_infos) > 0, "There must be at least one sysroot arch framework." + + # paths of the onnxruntime libraries for different archs + ort_libs = [(info.framework_dir / "onnxruntime") for info in sysroot_arch_framework_infos] + + # We only need to copy Info.plist, framework_info.json, and headers once since they are the same + info_plist_path = sysroot_arch_framework_infos[0].info_plist_file + framework_info_path = sysroot_arch_framework_infos[0].framework_info_file + header_paths = (sysroot_arch_framework_infos[0].framework_dir / "Headers").glob("*.h") # manually create the fat framework - framework_dir = os.path.join(intermediates_dir, "frameworks", sysroot, "onnxruntime.framework") + framework_dir = intermediates_dir / "frameworks" / sysroot / "onnxruntime.framework" # remove the existing framework if any - if os.path.exists(framework_dir): + if framework_dir.exists(): shutil.rmtree(framework_dir) - pathlib.Path(framework_dir).mkdir(parents=True, exist_ok=True) - - # copy the Info.plist, framework_info.json, and header files + framework_dir.mkdir(parents=True, exist_ok=True) # macos requires different framework structure: # https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPFrameworks/Concepts/FrameworkAnatomy.html - if sysroot == "macosx" or sysroot == "macabi": - # create headers and resources directory - header_dir = os.path.join(framework_dir, "Versions", "A", "Headers") - resource_dir = os.path.join(framework_dir, "Versions", "A", "Resources") - pathlib.Path(header_dir).mkdir(parents=True, exist_ok=True) - pathlib.Path(resource_dir).mkdir(parents=True, exist_ok=True) - - shutil.copy(info_plist_path, resource_dir) - shutil.copy(framework_info_path, os.path.dirname(framework_dir)) - - for _header in headers: - shutil.copy(_header, header_dir) - - # use lipo to create a fat ort library - lipo_command = ["lipo", "-create"] - lipo_command += ort_libs - lipo_command += ["-output", os.path.join(framework_dir, "Versions", "A", "onnxruntime")] - subprocess.run(lipo_command, shell=False, check=True) - - # create the symbolic link - pathlib.Path(os.path.join(framework_dir, "Versions", "Current")).symlink_to("A", target_is_directory=True) - pathlib.Path(os.path.join(framework_dir, "Headers")).symlink_to( - "Versions/Current/Headers", target_is_directory=True - ) - pathlib.Path(os.path.join(framework_dir, "Resources")).symlink_to( - "Versions/Current/Resources", target_is_directory=True - ) - pathlib.Path(os.path.join(framework_dir, "onnxruntime")).symlink_to("Versions/Current/onnxruntime") + use_macos_framework_structure = sysroot == "macosx" or sysroot == "macabi" + + if use_macos_framework_structure: + dst_header_dir = framework_dir / "Versions" / "A" / "Headers" + dst_resource_dir = framework_dir / "Versions" / "A" / "Resources" + dst_header_dir.mkdir(parents=True, exist_ok=True) + dst_resource_dir.mkdir(parents=True, exist_ok=True) + + dst_info_plist_path = dst_resource_dir / info_plist_path.name + dst_framework_info_path = framework_dir.parent / framework_info_path.name + dst_fat_library_path = framework_dir / "Versions" / "A" / "onnxruntime" else: - shutil.copy(info_plist_path, framework_dir) - shutil.copy(framework_info_path, os.path.dirname(framework_dir)) - header_dir = os.path.join(framework_dir, "Headers") - pathlib.Path(header_dir).mkdir(parents=True, exist_ok=True) + dst_header_dir = framework_dir / "Headers" + dst_header_dir.mkdir(parents=True, exist_ok=True) - for _header in headers: - shutil.copy(_header, header_dir) + dst_info_plist_path = framework_dir / info_plist_path.name + dst_framework_info_path = framework_dir.parent / framework_info_path.name - # use lipo to create a fat ort library - lipo_command = ["lipo", "-create"] - lipo_command += ort_libs - lipo_command += ["-output", os.path.join(framework_dir, "onnxruntime")] - subprocess.run(lipo_command, shell=False, check=True) + dst_fat_library_path = framework_dir / "onnxruntime" - return framework_dir + # copy the Info.plist, framework_info.json, and header files + shutil.copy(info_plist_path, dst_info_plist_path) + shutil.copy(framework_info_path, dst_framework_info_path) + + for header_path in header_paths: + shutil.copy(header_path, dst_header_dir) + + # use lipo to create a fat ort library + lipo_command = ["lipo", "-create"] + lipo_command += [str(lib) for lib in ort_libs] + lipo_command += ["-output", str(dst_fat_library_path)] + subprocess.run(lipo_command, shell=False, check=True) + + if use_macos_framework_structure: + # create additional symbolic links + (framework_dir / "Versions" / "Current").symlink_to("A", target_is_directory=True) + (framework_dir / "Headers").symlink_to("Versions/Current/Headers", target_is_directory=True) + (framework_dir / "Resources").symlink_to("Versions/Current/Resources", target_is_directory=True) + (framework_dir / "onnxruntime").symlink_to("Versions/Current/onnxruntime") + + return SysrootFrameworkInfo( + framework_dir=framework_dir, + framework_info_file=dst_framework_info_path, + ) -def _merge_framework_info_files(files, output_file): +def _merge_framework_info_files(files: list[pathlib.Path], output_file: pathlib.Path): merged_data = {} for file in files: @@ -153,68 +211,37 @@ def _merge_framework_info_files(files, output_file): json.dump(merged_data, f, indent=2) -def _build_package(args): - build_settings = _parse_build_settings(args) - build_dir = os.path.abspath(args.build_dir) - - # Temp dirs to hold building results - intermediates_dir = os.path.join(build_dir, "intermediates") - build_config = args.config - - # build framework for individual sysroot - framework_dirs = [] - framework_info_files_to_merge = [] - public_headers_path = "" - for sysroot in build_settings["build_osx_archs"]: - base_build_command = ( - [sys.executable, BUILD_PY] - + build_settings["build_params"]["base"] - + build_settings["build_params"][sysroot] - + ["--config=" + build_config] - ) - - if args.include_ops_by_config is not None: - base_build_command += ["--include_ops_by_config=" + str(args.include_ops_by_config.resolve())] - - if args.path_to_protoc_exe is not None: - base_build_command += ["--path_to_protoc_exe=" + str(args.path_to_protoc_exe.resolve())] - - framework_dir = _build_for_apple_sysroot( - build_config, - intermediates_dir, - base_build_command, - sysroot, - build_settings["build_osx_archs"][sysroot], - args.build_dynamic_framework, - ) - framework_dirs.append(framework_dir) +def _assemble_xcframework(build_dir: pathlib.Path, sysroot_framework_infos: list[SysrootFrameworkInfo]) -> pathlib.Path: + assert len(sysroot_framework_infos) > 0, "There must be at least one sysroot fat framework." - curr_framework_info_path = os.path.join(os.path.dirname(framework_dir), "framework_info.json") - framework_info_files_to_merge.append(curr_framework_info_path) + framework_dirs = [info.framework_dir for info in sysroot_framework_infos] + framework_info_files_to_merge = [info.framework_info_file for info in sysroot_framework_infos] - # headers for each sysroot are the same, pick one of them - if not public_headers_path: - public_headers_path = os.path.join(os.path.dirname(framework_dir), "onnxruntime.framework", "Headers") + # headers for each sysroot are the same, pick the first one + public_headers_path = sysroot_framework_infos[0].framework_dir / "Headers" - # create the folder for xcframework and copy the LICENSE and framework_info.json file - xcframework_dir = os.path.join(build_dir, "framework_out") - pathlib.Path(xcframework_dir).mkdir(parents=True, exist_ok=True) - shutil.copy(os.path.join(REPO_DIR, "LICENSE"), xcframework_dir) - shutil.copytree(public_headers_path, os.path.join(xcframework_dir, "Headers"), dirs_exist_ok=True, symlinks=True) - _merge_framework_info_files(framework_info_files_to_merge, os.path.join(build_dir, "xcframework_info.json")) + # create the output folder for the xcframework and copy the LICENSE and header files and generate the + # xcframework_info.json file + output_dir = build_dir / "framework_out" + output_dir.mkdir(parents=True, exist_ok=True) + shutil.copy(REPO_DIR / "LICENSE", output_dir) + shutil.copytree(public_headers_path, output_dir / "Headers", dirs_exist_ok=True, symlinks=True) + _merge_framework_info_files(framework_info_files_to_merge, build_dir / "xcframework_info.json") # remove existing xcframework if any - xcframework_path = os.path.join(xcframework_dir, "onnxruntime.xcframework") + xcframework_path = output_dir / "onnxruntime.xcframework" if os.path.exists(xcframework_path): shutil.rmtree(xcframework_path) # Assemble the final xcframework - build_xcframework_cmd = ["xcrun", "xcodebuild", "-create-xcframework", "-output", xcframework_path] + build_xcframework_cmd = ["xcrun", "xcodebuild", "-create-xcframework", "-output", str(xcframework_path)] for framework_dir in framework_dirs: - build_xcframework_cmd.extend(["-framework", framework_dir]) + build_xcframework_cmd.extend(["-framework", str(framework_dir)]) subprocess.run(build_xcframework_cmd, shell=False, check=True, cwd=REPO_DIR) + return xcframework_path + def parse_args(): parser = argparse.ArgumentParser( @@ -230,7 +257,7 @@ def parse_args(): parser.add_argument( "--build_dir", type=pathlib.Path, - default=os.path.join(REPO_DIR, "build/apple_framework"), + default=(REPO_DIR / "build" / "apple_framework"), help="Provide the root directory for build output", ) @@ -254,11 +281,40 @@ def parse_args(): help="Build Dynamic Framework (default is build static framework).", ) + parser.add_argument("--path_to_protoc_exe", type=pathlib.Path, help="Path to protoc exe.") + parser.add_argument( "build_settings_file", type=pathlib.Path, help="Provide the file contains settings for building iOS framework" ) - parser.add_argument("--path_to_protoc_exe", type=pathlib.Path, help="Path to protoc exe.") + mode_group = parser.add_mutually_exclusive_group() + + mode_group.add_argument( + "--only_build_single_sysroot_arch_framework", + nargs=2, + metavar=("sysroot", "arch"), + help="Only build the specified sysroot/arch framework. E.g., sysroot = iphoneos, arch = arm64. " + "This can be used to split up the builds between different invocations of this script. " + "The sysroot and arch combination should be one from the build settings file.", + ) + + mode_group.add_argument( + "--only_assemble_xcframework", + action="store_true", + help="Only assemble the xcframework (and intermediate fat frameworks) from the sysroot/arch frameworks. " + "This mode requires the necessary sysroot/arch frameworks to already be present in the build directory, " + "as if this script were previously invoked with `--only_build_single_sysroot_arch_framework` and the same " + "`--build_dir` value.", + ) + + parser.add_argument( + "--record_sysroot_arch_framework_build_outputs_to_file", + type=pathlib.Path, + help="If building sysroot/arch framework(s), write the build output file paths to the specified file. " + "The paths will be relative to the build directory specified by `--build_dir`. " + "These build output files are the files that should be preserved between split-build invocations with " + "`--only_build_single_sysroot_arch_framework` and `--only_assemble_xcframework`.", + ) args = parser.parse_args() @@ -275,7 +331,92 @@ def parse_args(): def main(): args = parse_args() - _build_package(args) + + build_settings_file = args.build_settings_file.resolve() + build_settings = parse_build_settings_file(build_settings_file) + + build_dir = args.build_dir.resolve() + build_config = args.config + + all_sysroot_arch_pairs = get_sysroot_arch_pairs(build_settings) + + # default to building frameworks and assembling xcframework + do_sysroot_arch_framework_build = do_xcframework_assembly = True + + # mode options may modify the default behavior + if args.only_build_single_sysroot_arch_framework is not None: + do_xcframework_assembly = False + if args.only_assemble_xcframework: + do_sysroot_arch_framework_build = False + + # directory for intermediate build files + intermediates_dir = build_dir / "intermediates" + intermediates_dir.mkdir(parents=True, exist_ok=True) + + sysroot_to_sysroot_arch_framework_infos: dict[str, list[SysrootArchFrameworkInfo]] = {} + + if do_sysroot_arch_framework_build: + # build sysroot/arch frameworks + sysroot_arch_pairs_to_build = _filter_sysroot_arch_pairs(all_sysroot_arch_pairs, args) + + # common build command trailing args + build_command_trailing_args = [f"--config={build_config}"] + if args.include_ops_by_config is not None: + build_command_trailing_args += [f"--include_ops_by_config={args.include_ops_by_config.resolve()}"] + if args.path_to_protoc_exe is not None: + build_command_trailing_args += [f"--path_to_protoc_exe={args.path_to_protoc_exe.resolve()}"] + + built_sysroot_arch_framework_infos: list[SysrootArchFrameworkInfo] = [] + + for sysroot, arch in sysroot_arch_pairs_to_build: + infos_for_sysroot = sysroot_to_sysroot_arch_framework_infos.setdefault(sysroot, []) + base_build_command = [ + sys.executable, + BUILD_PY, + *get_build_params(build_settings, "base"), + *get_build_params(build_settings, sysroot), + *build_command_trailing_args, + ] + + info = _find_or_build_sysroot_arch_framework( + build_config, intermediates_dir, base_build_command, sysroot, arch, args.build_dynamic_framework + ) + infos_for_sysroot.append(info) + built_sysroot_arch_framework_infos.append(info) + + print(f"Built sysroot/arch framework for {sysroot}/{arch}: {info.framework_dir}") + + if args.record_sysroot_arch_framework_build_outputs_to_file is not None: + _write_sysroot_arch_framework_build_outputs_to_file( + build_dir, built_sysroot_arch_framework_infos, args.record_sysroot_arch_framework_build_outputs_to_file + ) + + else: + # do not build sysroot/arch frameworks, but look for existing ones + for sysroot, arch in all_sysroot_arch_pairs: + infos_for_sysroot = sysroot_to_sysroot_arch_framework_infos.setdefault(sysroot, []) + base_build_command = None # do not build anything + info = _find_or_build_sysroot_arch_framework( + build_config, intermediates_dir, base_build_command, sysroot, arch, args.build_dynamic_framework + ) + infos_for_sysroot.append(info) + + print(f"Found existing sysroot/arch framework for {sysroot}/{arch}: {info.framework_dir}") + + if do_xcframework_assembly: + sysroot_framework_infos: list[SysrootFrameworkInfo] = [] + + # assemble fat frameworks + for sysroot, sysroot_arch_framework_infos in sysroot_to_sysroot_arch_framework_infos.items(): + sysroot_framework_info = _assemble_fat_framework_for_sysroot( + intermediates_dir, sysroot, sysroot_arch_framework_infos + ) + sysroot_framework_infos.append(sysroot_framework_info) + + # assemble xcframework + xcframework_dir = _assemble_xcframework(build_dir, sysroot_framework_infos) + + print(f"Assembled xcframework: {xcframework_dir}") if __name__ == "__main__": diff --git a/tools/ci_build/github/apple/build_settings_utils.py b/tools/ci_build/github/apple/build_settings_utils.py new file mode 100644 index 0000000000000..cd0c76c5a0ecc --- /dev/null +++ b/tools/ci_build/github/apple/build_settings_utils.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import json +import pathlib +import typing + +_DEFAULT_BUILD_SYSROOT_ARCHS = { + "iphoneos": ["arm64"], + "iphonesimulator": ["arm64", "x86_64"], +} + + +def parse_build_settings_file(build_settings_file: pathlib.Path) -> dict[str, typing.Any]: + """ + Parses the provided build settings file into a build settings dict. + + :param build_settings_file: The build settings file path. + :type build_settings_file: pathlib.Path + :return: The build settings dict. + :rtype: dict[str, Any] + """ + + def check(condition: bool, message: str): + if not condition: + raise ValueError(message) + + # validate that `input` is a dict[str, list[str]] + def validate_str_to_str_list_dict(input: dict[str, list[str]]): + check(isinstance(input, dict), f"input is not a dict: {input}") + for key, value in input.items(): + check(isinstance(key, str), f"key is not a string: {key}") + check(isinstance(value, list), f"value is not a list: {value}") + for value_element in value: + check(isinstance(value_element, str), f"list element is not a string: {value_element}") + + with open(build_settings_file) as f: + build_settings_data = json.load(f) + + build_settings = {} + + build_osx_archs = build_settings_data.get("build_osx_archs", _DEFAULT_BUILD_SYSROOT_ARCHS) + validate_str_to_str_list_dict(build_osx_archs) + build_settings["build_osx_archs"] = build_osx_archs + + build_params = build_settings_data.get("build_params", {}) + validate_str_to_str_list_dict(build_params) + build_settings["build_params"] = build_params + + return build_settings + + +def get_sysroot_arch_pairs(build_settings: dict) -> list[tuple[str, str]]: + """ + Gets all specified sysroot/arch pairs. + + :param build_settings: The build settings dict. + :type build_settings: dict + :return: A list of (sysroot, arch) tuples. + :rtype: list[tuple[str, str]] + """ + pair_set: set[tuple[str, str]] = set() + for sysroot, archs in build_settings["build_osx_archs"].items(): + for arch in archs: + pair_set.add((sysroot, arch)) + + return sorted(pair_set) + + +def get_build_params(build_settings: dict, sysroot: str) -> list[str]: + """ + Returns the build params associated with given `sysroot`. + The special `sysroot` value "base" may be used to get the base build params. + + :param build_settings: The build settings dict. + :type build_settings: dict + :param sysroot: The specified sysroot. + :type sysroot: str + :return: The build params associated with `sysroot`, if any, or an empty list. + :rtype: list[str] + """ + return build_settings["build_params"].get(sysroot, []) diff --git a/tools/ci_build/github/azure-pipelines/mac-ios-packaging-pipeline.yml b/tools/ci_build/github/azure-pipelines/mac-ios-packaging-pipeline.yml index 351cab9cfd171..4bcf4b05e77c0 100644 --- a/tools/ci_build/github/azure-pipelines/mac-ios-packaging-pipeline.yml +++ b/tools/ci_build/github/azure-pipelines/mac-ios-packaging-pipeline.yml @@ -56,7 +56,7 @@ extends: # Update the pool with your team's 1ES hosted pool. pool: name: "Azure Pipelines" - image: "macOS-14" + image: "macOS-15" os: macOS sdl: sourceAnalysisPool: @@ -67,3 +67,5 @@ extends: - template: templates/stages/mac-ios-packaging-build-stage.yml parameters: buildType: ${{ parameters.buildType }} + xcodeVersion: "16.4" + iosSimulatorRuntimeVersion: "18.5" diff --git a/tools/ci_build/github/azure-pipelines/templates/setup-build-tools.yml b/tools/ci_build/github/azure-pipelines/templates/setup-build-tools.yml index 548ff8a54a854..f9441a9b74c4a 100644 --- a/tools/ci_build/github/azure-pipelines/templates/setup-build-tools.yml +++ b/tools/ci_build/github/azure-pipelines/templates/setup-build-tools.yml @@ -8,7 +8,7 @@ parameters: - name: python_version type: string default: '3.12' - + - name: action_version type: string default: 'v0.0.9' @@ -17,17 +17,9 @@ steps: - template: telemetry-steps.yml - task: UsePythonVersion@0 - displayName: 'Use Python ${{ parameters.host_cpu_arch }} (macOS)' - condition: and(succeeded(), eq(variables['Agent.OS'], 'Darwin')) - inputs: - versionSpec: ${{ parameters.python_version }} - architecture: ${{ parameters.host_cpu_arch }} - -- task: UsePythonVersion@0 - displayName: 'Use Python ${{ parameters.host_cpu_arch }} (non-macOS)' - condition: and(succeeded(), ne(variables['Agent.OS'], 'Darwin')) + displayName: 'Use Python ${{ parameters.python_version }} ${{ parameters.host_cpu_arch }}' inputs: - versionSpec: ${{ parameters.python_version }} + versionSpec: ${{ parameters.python_version }} architecture: ${{ parameters.host_cpu_arch }} - task: PipAuthenticate@1 @@ -45,17 +37,18 @@ steps: displayName: 'Setup Latest Node.js v20 (Win)' condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) inputs: - filePath: '$(System.DefaultWorkingDirectory)\tools\ci_build\github\windows\setup_nodejs.ps1' + filePath: '$(System.DefaultWorkingDirectory)\tools\ci_build\github\windows\setup_nodejs.ps1' arguments: '-MajorVersion 22' - script: | node -v npm -v - condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) - displayName: 'Verify Node.js Version' + displayName: 'Display Node.js Version' -- script: python3 -m pip install requests +- script: | + python3 -m pip install requests + displayName: 'Install Python requests package' - task: PythonScript@0 displayName: 'Run GitHub Action via Python Wrapper' diff --git a/tools/ci_build/github/azure-pipelines/templates/stages/mac-ios-packaging-build-stage.yml b/tools/ci_build/github/azure-pipelines/templates/stages/mac-ios-packaging-build-stage.yml index 7d6e272533696..16b7489241e5a 100644 --- a/tools/ci_build/github/azure-pipelines/templates/stages/mac-ios-packaging-build-stage.yml +++ b/tools/ci_build/github/azure-pipelines/templates/stages/mac-ios-packaging-build-stage.yml @@ -4,33 +4,131 @@ parameters: values: - release - normal - default: normal + +# Note: Keep the Xcode version and iOS simulator version compatible. +# Check the table here to see what iOS simulator versions are supported by a particular Xcode version: +# https://developer.apple.com/support/xcode/ + +- name: xcodeVersion + type: string + +- name: iosSimulatorRuntimeVersion + type: string + +- name: buildSettingsFile + type: string + default: "tools/ci_build/github/apple/default_full_apple_framework_build_settings.json" + +- name: podNamePrefix + type: string + default: "onnxruntime" stages: -- stage: IosPackaging_Build +# This stage builds the individual sysroot/arch frameworks. +# Since the number of framework build jobs is dynamic, these jobs are put into this stage to allow a later stage to +# wait for all of them to complete. +- stage: BuildFrameworks dependsOn: [] + jobs: - - job: - displayName: "Build iOS package" - - variables: - # Note: Keep the Xcode version and iOS simulator version compatible. - # Check the table here to see what iOS simulator versions are supported by a particular Xcode version: - # https://developer.apple.com/support/xcode/ - xcodeVersion: "16.2" - iosSimulatorRuntimeVersion: "18.2" - buildSettingsFile: "tools/ci_build/github/apple/default_full_apple_framework_build_settings.json" - cPodName: onnxruntime-c - objcPodName: onnxruntime-objc - timeoutInMinutes: 270 - templateContext: - outputs: - - output: pipelineArtifact - targetPath: $(Build.ArtifactStagingDirectory) - artifactName: ios_packaging_artifacts_full + - job: SetUpBuildFrameworkJobMatrix + + steps: + - task: PythonScript@0 + name: SetVariables + inputs: + scriptSource: "inline" + script: | + import json + import sys + + utils_path = "$(Build.SourcesDirectory)/tools/ci_build/github/apple" + sys.path.insert(0, utils_path) + + from build_settings_utils import parse_build_settings_file, get_sysroot_arch_pairs + + build_settings_file = "${{ parameters.buildSettingsFile }}" + build_settings = parse_build_settings_file(build_settings_file) + sysroot_arch_pairs = get_sysroot_arch_pairs(build_settings) + + job_matrix = {} + for sysroot, arch in sysroot_arch_pairs: + identifier = f"{sysroot}_{arch}" + job_matrix[identifier] = { + "sysroot": sysroot, + "arch": arch, + } + + job_matrix_json = json.dumps(job_matrix) + + print(f"Build framework job matrix:\n{job_matrix_json}") + print(f"##vso[task.setvariable variable=BuildFrameworkJobMatrix;isOutput=true]{job_matrix_json}") + displayName: "Generate build framework job matrix" + + - job: BuildFramework + dependsOn: SetUpBuildFrameworkJobMatrix + + strategy: + maxParallel: 8 + matrix: $[ dependencies.SetUpBuildFrameworkJobMatrix.outputs['SetVariables.BuildFrameworkJobMatrix'] ] + + timeoutInMinutes: 120 steps: - - bash: | + - template: ../setup-build-tools.yml + parameters: + host_cpu_arch: arm64 + + - template: ../use-xcode-version.yml + parameters: + xcodeVersion: ${{ parameters.xcodeVersion }} + + - script: | + pip install -r tools/ci_build/github/apple/ios_packaging/requirements.txt + displayName: "Install Python requirements" + + - script: | + python tools/ci_build/github/apple/build_apple_framework.py \ + --build_dir "$(Build.BinariesDirectory)/build" \ + --only_build_single_sysroot_arch_framework "$(sysroot)" "$(arch)" \ + --record_sysroot_arch_framework_build_outputs_to_file "$(Build.BinariesDirectory)/build_outputs.txt" \ + "${{ parameters.buildSettingsFile }}" + displayName: "Build framework for $(sysroot)/$(arch)" + + - script: | + set -e + + BUILD_OUTPUTS_FILE="$(Build.BinariesDirectory)/build_outputs.txt" + BUILD_DIR="$(Build.BinariesDirectory)/build" + + cd "${BUILD_DIR}" + zip \ + --recurse-paths \ + --symlinks \ + --names-stdin \ + "$(Build.ArtifactStagingDirectory)/build.zip" \ + < "${BUILD_OUTPUTS_FILE}" + displayName: "Create framework build archive artifact" + + - task: 1ES.PublishPipelineArtifact@1 + inputs: + path: $(Build.ArtifactStagingDirectory) + artifact: framework_$(sysroot)_$(arch) + displayName: "Publish artifact - framework_$(sysroot)_$(arch)" + +# This stage assembles the frameworks built in the previous stage into a packaging artifacts and tests them. +- stage: AssemblePackageAndTest + dependsOn: BuildFrameworks + + variables: + cPodName: ${{ parameters.podNamePrefix }}-c + objcPodName: ${{ parameters.podNamePrefix }}-objc + + jobs: + - job: AssemblePackageAndTest + + steps: + - script: | set -e BUILD_TYPE="${{ parameters.buildType }}" @@ -50,16 +148,8 @@ stages: # Do not output ##vso[] commands with `set -x` or they may be parsed again and include a trailing quote. set +x echo "##vso[task.setvariable variable=ortPodVersion;]${VERSION}" - echo "ortPodVersion : ${ortPodVersion}, VERSION : ${VERSION}" - displayName: "Set common variables" - name: SetCommonVariables - - - script: | - if [[ -z "$(ortPodVersion)" ]]; then - echo "ORT pod version is unspecified. Make sure that the IosPackaging_SetCommonVariables stage has run." - exit 1 - fi - displayName: 'Ensure version is set' + echo "ortPodVersion: ${VERSION}" + displayName: "Set ortPodVersion variable" - task: InstallAppleCertificate@2 inputs: @@ -81,29 +171,44 @@ stages: - template: ../use-xcode-version.yml parameters: - xcodeVersion: $(xcodeVersion) + xcodeVersion: ${{ parameters.xcodeVersion }} - script: | pip install -r tools/ci_build/github/apple/ios_packaging/requirements.txt displayName: "Install Python requirements" - # create and test mobile pods + - task: DownloadPipelineArtifact@2 + inputs: + itemPattern: framework_*/build.zip + targetPath: $(Build.BinariesDirectory)/artifacts + displayName: "Download framework build archive artifacts" + + - script: | + set -e + BUILD_DIR="$(Build.BinariesDirectory)/build" + mkdir -p "${BUILD_DIR}" + find "$(Build.BinariesDirectory)/artifacts" -name "build.zip" -exec unzip {} -d "${BUILD_DIR}" \; + displayName: "Extract framework build archive artifacts to build directory" + + # Assemble the xcframework and the pod packages. + # The frameworks from the BuildFrameworks stage should already be in the build directory. - script: | python tools/ci_build/github/apple/build_and_assemble_apple_pods.py \ - --build-dir "$(Build.BinariesDirectory)/apple_framework" \ + --build-dir "$(Build.BinariesDirectory)/build" \ --staging-dir "$(Build.BinariesDirectory)/staging" \ --pod-version "$(ortPodVersion)" \ --test \ - --build-settings-file "${{ variables.buildSettingsFile }}" - displayName: "Build macOS/iOS framework and assemble pod package files" + --build-apple-framework-arg=--only_assemble_xcframework \ + --build-settings-file "${{ parameters.buildSettingsFile }}" + displayName: "Assemble pod package files" env: - ORT_GET_SIMULATOR_DEVICE_INFO_REQUESTED_RUNTIME_VERSION: $(iosSimulatorRuntimeVersion) + ORT_GET_SIMULATOR_DEVICE_INFO_REQUESTED_RUNTIME_VERSION: ${{ parameters.iosSimulatorRuntimeVersion }} - script: | python tools/ci_build/github/apple/test_apple_packages.py \ --fail_if_cocoapods_missing \ - --framework_info_file "$(Build.BinariesDirectory)/apple_framework/xcframework_info.json" \ - --c_framework_dir "$(Build.BinariesDirectory)/apple_framework/framework_out" \ + --framework_info_file "$(Build.BinariesDirectory)/build/xcframework_info.json" \ + --c_framework_dir "$(Build.BinariesDirectory)/build/framework_out" \ --test_project_stage_dir "$(Build.BinariesDirectory)/app_center_test" \ --prepare_test_project_only displayName: "Assemble test project for App Center" @@ -126,7 +231,9 @@ stages: displayName: 'Build App Center iPhone arm64 tests' - script: | - zip -r --symlinks $(Build.ArtifactStagingDirectory)/package_tests.zip ios_package_testUITests-Runner.app + set -e -x + mkdir -p $(Build.ArtifactStagingDirectory)/package_test + zip -r --symlinks $(Build.ArtifactStagingDirectory)/package_test/package_tests.zip ios_package_testUITests-Runner.app workingDirectory: '$(Build.BinariesDirectory)/app_center_test/apple_package_test/DerivedData/Build/Products/Debug-iphoneos' displayName: "Create .zip file of the tests" @@ -164,7 +271,7 @@ stages: xcodebuild -exportArchive \ -archivePath ios_package_test.xcarchive \ -exportOptionsPlist exportOptions.plist \ - -exportPath $(Build.ArtifactStagingDirectory)/test_ipa + -exportPath $(Build.ArtifactStagingDirectory)/package_test/test_ipa workingDirectory: '$(Build.BinariesDirectory)/app_center_test/apple_package_test/' displayName: "Create .ipa file" @@ -172,17 +279,17 @@ stages: # so that users can attempt to locally debug - task: 1ES.PublishPipelineArtifact@1 inputs: - path: $(Build.ArtifactStagingDirectory) + path: $(Build.ArtifactStagingDirectory)/package_test artifact: browserstack_test_artifacts_full - displayName: "Publish BrowserStack artifacts" + displayName: "Publish artifact - browserstack_test_artifacts_full" - script: | set -e -x pip install requests python $(Build.SourcesDirectory)/tools/python/upload_and_run_browserstack_tests.py \ --test_platform xcuitest \ - --app_path "$(Build.ArtifactStagingDirectory)/test_ipa/ios_package_test.ipa" \ - --test_path "$(Build.ArtifactStagingDirectory)/package_tests.zip" \ + --app_path "$(Build.ArtifactStagingDirectory)/package_test/test_ipa/ios_package_test.ipa" \ + --test_path "$(Build.ArtifactStagingDirectory)/package_test/package_tests.zip" \ --devices "iPhone 15-17" displayName: Run E2E tests using Browserstack workingDirectory: $(Build.BinariesDirectory)/app_center_test/apple_package_test @@ -194,18 +301,27 @@ stages: - script: | set -e -x + mkdir -p "$(Build.ArtifactStagingDirectory)/package" + for POD_NAME in "${{ variables.cPodName}}" "${{ variables.objcPodName }}"; do ./tools/ci_build/github/apple/assemble_apple_packaging_artifacts.sh \ "$(Build.BinariesDirectory)/staging" \ - "$(Build.ArtifactStagingDirectory)" \ + "$(Build.ArtifactStagingDirectory)/package" \ "${POD_NAME}" \ "$(ortPodVersion)" done # copy over helper script for use in release pipeline - cp tools/ci_build/github/apple/package_release_tasks.py "$(Build.ArtifactStagingDirectory)" - displayName: "Assemble artifacts" + cp tools/ci_build/github/apple/package_release_tasks.py "$(Build.ArtifactStagingDirectory)/package" + displayName: "Assemble packaging artifacts" + + # Publish packaging artifacts + - task: 1ES.PublishPipelineArtifact@1 + inputs: + path: $(Build.ArtifactStagingDirectory)/package + artifact: ios_packaging_artifacts_full + displayName: "Publish artifact - ios_packaging_artifacts_full" - script: | set -e -x