#!/usr/bin/env python3 import argparse from concurrent.futures import ThreadPoolExecutor, as_completed import hashlib import json import os from pathlib import Path import shutil import subprocess import sys import time import zipfile RIDS = ( 'osx-arm64', 'osx-x64', 'linux-x64', 'linux-arm64', 'linux-musl-x64', 'linux-musl-arm64', 'win-x64', 'win-x86', 'win-arm64', ) def run(command, logfile, rid=None, env=None): prefix = f'[{rid}] ' if rid else '' print(prefix + '+ ' + ' '.join(map(str, command)), flush=True) with logfile.open('a') as output: process = subprocess.Popen(list(map(str, command)), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env, errors='replace') try: for line in process.stdout: print(prefix + line, end='', flush=True) output.write(line) except BaseException: process.terminate() process.wait() raise return process.wait() def isolate_sources(manifest, rid): sources = json.loads(manifest.read_text()) source_lock = Path(sources['source_lock']) version = hashlib.sha256(source_lock.read_bytes()).hexdigest()[:16] workspace = Path('External/NativeStatic/.work/parallel-sources').resolve() / version / rid workspace.mkdir(parents=True, exist_ok=True) isolated_manifest = workspace / 'cross-sources.json' if isolated_manifest.is_file(): return isolated_manifest, workspace for incomplete in (workspace / 'SkiaSharp', workspace / 'ANGLE'): if incomplete.exists(): shutil.rmtree(incomplete) skia = Path(sources['skia']) angle = Path(sources['angle']) parent = workspace / 'SkiaSharp' isolated_skia = parent / 'externals/skia' isolated_angle = workspace / 'ANGLE' for original, destination in ((skia.parent.parent, parent), (skia, isolated_skia), (angle, isolated_angle)): if not (destination / '.git').is_dir(): destination.parent.mkdir(parents=True, exist_ok=True) subprocess.run(['git', 'clone', '--shared', '--no-checkout', str(original), str(destination)], check=True) ignored = shutil.ignore_patterns('.git', 'out', 'staticlink_zig_*') for original, destination in ((skia, isolated_skia), (angle, isolated_angle)): shutil.copytree(original, destination, dirs_exist_ok=True, symlinks=True, ignore=ignored) sources.update(skia=str(isolated_skia), angle=str(isolated_angle), angle_gn=str(isolated_angle / Path(sources['angle_gn']).relative_to(angle))) isolated_manifest.write_text(json.dumps(sources, indent=2) + '\n') return isolated_manifest, workspace def build_target(rid, args, lock, jobs): print(f'=== {rid}: compile and link with {jobs} jobs ===', flush=True) started = time.monotonic() logfile = args.results / (rid + '.log') status = 1 failure = None try: manifest, workspace = isolate_sources(args.sources_manifest, rid) env = os.environ.copy() env['ZIG_GLOBAL_CACHE_DIR'] = str(workspace / 'zig-global') libraries = Path('External/NativeStatic/zig-cross') / lock['skiasharp_version'] / rid native = Path('External/NativeStatic') / rid / 'native' command = [sys.executable, 'build/linux-toolchain/build-cross.py', '--target', rid, '--sources-manifest', manifest, '--work', workspace / 'toolchains', '--output', libraries, '--jobs', str(jobs)] status = run(command, logfile, rid, env) if status == 0: status = run([sys.executable, 'build/linux-toolchain/package-zig.py', '--rid', rid, '--input', libraries, '--output', native], logfile, rid, env) if status == 0: shutil.copytree(native, args.results / rid / 'native', dirs_exist_ok=True) shutil.copytree(libraries / 'link-tests', args.results / rid / 'link-tests', dirs_exist_ok=True) except Exception as error: status = 1 failure = f'{type(error).__name__}: {error}' print(f'[{rid}] {failure}', flush=True) print(f'=== {rid}: {"PASS" if status == 0 else "FAIL"} ===', flush=True) return {'exit_code': status, 'passed': status == 0, 'error': failure, 'elapsed_seconds': round(time.monotonic() - started, 1)} def main(): parser = argparse.ArgumentParser() parser.add_argument('--sources-manifest', type=Path, required=True) parser.add_argument('--results', type=Path, required=True) args = parser.parse_args() args.results.mkdir(parents=True, exist_ok=True) lock = json.loads(Path('build/linux-toolchain/release.lock.json').read_text()) processors = int(subprocess.check_output(['nproc'], text=True).strip()) workers = min(2, processors) jobs = min(22, max(1, processors // workers)) report = {'sources': lock, 'jobs': jobs, 'parallel_platforms': workers, 'targets': {}, 'package': 'not-built', 'runtime_tests': 'not performed; cross compilation and executable linking only'} descriptor = args.results / 'nine-platform-results.json' def save(): descriptor.write_text(json.dumps(report, indent=2) + '\n') save() print(f'Parallel platforms: {workers}; compile jobs per platform: {jobs}', flush=True) with ThreadPoolExecutor(max_workers=workers) as executor: pending = {executor.submit(build_target, rid, args, lock, jobs): rid for rid in RIDS} for future in as_completed(pending): report['targets'][pending[future]] = future.result() save() failed = [rid for rid in RIDS if not report['targets'][rid]['passed']] if failed: print('Failed platforms: ' + ', '.join(failed), flush=True) return 1 os.environ['SOURCE_LOCK_JSON'] = json.dumps(lock) os.environ['PACKAGE_VERSION'] = lock['skiasharp_version'] + '-' + lock['angle_branch'] + '.2' logfile = args.results / 'pack.log' status = run([sys.executable, 'scripts/apply-release-version.py'], logfile) if status == 0: status = run(['dotnet', 'pack', 'NuGet/StaticGraphics/StaticLink.Avalonia.csproj', '-c', 'Release', '-o', args.results / 'nuget'], logfile) if status == 0: package = next((args.results / 'nuget').glob('*.nupkg')) with zipfile.ZipFile(package) as archive: names = set(archive.namelist()) required = {'buildTransitive/StaticLink.Avalonia.props', 'buildTransitive/StaticLink.Avalonia.targets'} required.update('static/' + rid + '/native/' + ('skia.lib' if rid.startswith('win-') else 'libskia.a') for rid in RIDS) if not required.issubset(names) or any('\\' in name for name in names): print('NuGet package is missing target archives or has non-portable paths', flush=True) status = 1 report['package'] = 'passed' if status == 0 else 'failed' save() print('Nine-platform build/link/package: ' + report['package'], flush=True) return status if __name__ == '__main__': raise SystemExit(main())