diff --git a/.github/workflows/build_osx.yml b/.github/workflows/build_osx.yml index 6d28182b48..93a202820f 100644 --- a/.github/workflows/build_osx.yml +++ b/.github/workflows/build_osx.yml @@ -135,79 +135,7 @@ jobs: - name: Package .zip archives run: | - VERSION=v`cat VERSION` - # packaging/build.py stages the connector binary + connector.json - # into dist/autodesk/; the .app loop below copies that folder into - # the bundle. Same on-disk shape as the Linux and Windows builds. - uv run src/bonsaiviewer-autodesk/packaging/build.py - autodesk_connector_dir="$PWD/src/bonsaiviewer-autodesk/dist/autodesk" - test -d "$autodesk_connector_dir" - - cd ./build/`uname`/*/10.15/install/ifcopenshell - mkdir -p ~/output - install_root="$PWD" - - stage_runtime_payload() { - dest="$1" - while IFS= read -r runtime_file; do - cp -L "$runtime_file" "$dest/" - done < <( - for runtime_dir in "$install_root/bin" "$install_root/lib" "$install_root/lib64"; do - [ -d "$runtime_dir" ] || continue - find "$runtime_dir" -type f \( -name "*.so" -o -name "*.so.*" -o -name "*.dylib" -o -name "*.dll" \) - done - ) - } - - ls -d python-* | while read py_version; do - postfix=`echo ${py_version: -1} | sed s/[0-9]//` - numbers=`echo $py_version | grep -oE '[0-9]+\.[0-9]+' | tr -d '.'` - py_version_major=python-${numbers}$postfix - pushd . > /dev/null - cd $py_version - if [ ! -d ifcopenshell ]; then - mkdir ../ifcopenshell_ - mv * ../ifcopenshell_ - mv ../ifcopenshell_ ifcopenshell - fi - [ -d ifcopenshell/__pycache__ ] && rm -rf ifcopenshell/__pycache__ - find ifcopenshell -name "*.pyc" -delete - stage_runtime_payload ifcopenshell - zip -r -qq ifcopenshell-${py_version_major}-${VERSION}-${GITHUB_SHA:0:7}-macos${{ matrix.oldarch }}64.zip ifcopenshell - mv *.zip ~/output - popd > /dev/null - done - - find "$install_root/bin" -maxdepth 1 -type f -perm /111 ! -name "*.zip" ! -name "*.so" ! -name "*.so.*" ! -name "*.dylib" ! -name "*.dll" | while read exe_path; do - exe=`basename "$exe_path"` - package_dir="$install_root/.package-${exe}" - rm -rf "$package_dir" - mkdir -p "$package_dir" - cp "$exe_path" "$package_dir/" - stage_runtime_payload "$package_dir" - pushd "$package_dir" > /dev/null - zip -qq -r "$HOME/output/${exe}-${VERSION}-${GITHUB_SHA:0:7}-macos${{ matrix.oldarch }}64.zip" . - popd > /dev/null - rm -rf "$package_dir" - done - - # .app bundles (e.g. BonsaiViewer.app) live at the install-prefix - # root because their install rule uses `BUNDLE DESTINATION "."` — - # that's the layout Qt's macdeployqt expects. macdeployqt has - # already embedded the Qt frameworks inside each bundle during - # install/strip, so the only thing left to stage is the connector. - find "$install_root" -maxdepth 1 -type d -name "*.app" | while read app_path; do - app=`basename "$app_path" .app` - if [ "$app" = "BonsaiViewer" ]; then - # ConnectorDiscovery looks in applicationDirPath()/connectors, - # which for a bundle is Contents/MacOS. - mkdir -p "$app_path/Contents/MacOS/connectors" - cp -a "$autodesk_connector_dir" "$app_path/Contents/MacOS/connectors/" - fi - pushd "$install_root" > /dev/null - zip -qq -r "$HOME/output/${app}-${VERSION}-${GITHUB_SHA:0:7}-macos${{ matrix.oldarch }}64.zip" "$(basename "$app_path")" - popd > /dev/null - done + uv run nix/package-zip-archives.py "macos${{ matrix.oldarch }}64" - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v6 diff --git a/.github/workflows/build_rocky.yml b/.github/workflows/build_rocky.yml index d6c3f50c6d..3f506d9982 100644 --- a/.github/workflows/build_rocky.yml +++ b/.github/workflows/build_rocky.yml @@ -110,170 +110,7 @@ jobs: - name: Package .zip archives shell: bash run: | - VERSION=v`cat VERSION` - # bonsaiviewer-autodesk is now a Rust connector. packaging/build.py - # invokes `cargo build --release` and stages the binary + - # connector.json into dist/autodesk/. Same on-disk shape as the - # old PyInstaller flow so the symlink + zip steps below - # continue to work unchanged. - uv run src/bonsaiviewer-autodesk/packaging/build.py - autodesk_connector_dir="$PWD/src/bonsaiviewer-autodesk/dist/autodesk" - test -d "$autodesk_connector_dir" - - cd ./build/`uname`/*/install/ifcopenshell - mkdir -p ~/output - install_root="$PWD" - QT6_VERSION="${QT6_VERSION:-6.8.3}" - - if [ -z "${QT_DIR:-}" ]; then - for qt_candidate in "$(dirname "$install_root")"/qt6-${QT6_VERSION}-*/${QT6_VERSION}/*; do - if [ -d "$qt_candidate/lib" ]; then - QT_DIR="$qt_candidate" - break - fi - done - fi - - # Ensure that all shared libraries in provided dest `$1` - # are present using their SONAMEs (at least as symlinks). - ensure_soname_links() { - dest="$1" - find "$dest" -maxdepth 1 -type f -name "*.so*" | while IFS= read -r shared_object; do - # TODO: actual pattern is "Library soname" instead of "Shared library"? - soname=$(readelf -d "$shared_object" 2>/dev/null | sed -n 's/.*(SONAME).*Shared library: \[\(.*\)\].*/\1/p' | head -n 1) - [ -n "$soname" ] || continue - [ -e "$dest/$soname" ] && continue - ln -s "$(basename "$shared_object")" "$dest/$soname" - done - } - - # Copy all libs from `install/ifcopenshell` to the provided `$1`. - # Set `$2` to `0` to skip including geometry writers. - stage_runtime_payload() { - dest="$1" - include_geometry_writers="${2:-1}" - while IFS= read -r runtime_file; do - if [ "$include_geometry_writers" != "1" ] && [[ "$(basename "$runtime_file")" == ifcopenshell.geometry.writer.* ]]; then - continue - fi - cp -P "$runtime_file" "$dest/" - done < <( - for runtime_dir in "$install_root/bin" "$install_root/lib" "$install_root/lib64"; do - [ -d "$runtime_dir" ] || continue - find "$runtime_dir" \( -type f -o -type l \) \( -name "*.so" -o -name "*.so.*" -o -name "*.dylib" -o -name "*.dll" \) - done - ) - ensure_soname_links "$dest" - } - - # Copy all libs from `QT_DIR` to the provided `$2`. - stage_qt_runtime_payload() { - exe_path="$1" - dest="$2" - [ -n "${QT_DIR:-}" ] && [ -d "$QT_DIR/lib" ] || return 0 - - # Skip executables that don't depend on QT (don't have `libQt6` referenced). - if ! LD_LIBRARY_PATH="$QT_DIR/lib:${LD_LIBRARY_PATH:-}" ldd "$exe_path" 2>/dev/null | grep -q "libQt6"; then - return 0 - fi - - # Copy all QT libs to `dest`. - find "$QT_DIR/lib" -maxdepth 1 \( -type f -o -type l \) -name "*.so*" -exec cp -P {} "$dest/" \; - ensure_soname_links "$dest" - - # Copy QT plugins. - if [ -d "$QT_DIR/plugins" ]; then - pushd "$QT_DIR/plugins" > /dev/null - find . \( -type f -o -type l \) -name "*.so*" | while IFS= read -r plugin_file; do - mkdir -p "$dest/plugins/$(dirname "$plugin_file")" - cp -P "$plugin_file" "$dest/plugins/$plugin_file" - done - popd > /dev/null - # Point plugins rpath to `$dest`. - if [ -d "$dest/plugins" ]; then - find "$dest/plugins" -type f -name "*.so*" -exec patchelf --set-rpath '$ORIGIN/../..:$ORIGIN' {} \; - fi - fi - - find "$dest" -maxdepth 1 -type f -name "*.so*" -exec patchelf --set-rpath '$ORIGIN' {} \; - - printf "[Paths]\nPrefix = .\n" > "$dest/qt.conf" - } - - # Check all binaries in the dest `$1` - # and report if they're still missing dependencies or are static. - check_runtime_dependencies() { - package_dir="$1" - missing=0 - # Iterate over all .so files. - while IFS= read -r binary_file; do - # Skip non-binaries. - readelf -h "$binary_file" >/dev/null 2>&1 || continue - # Report non-dynamic binaries. - if ! env -u LD_LIBRARY_PATH ldd "$binary_file" > "$package_dir/.ldd.out" 2>&1; then - echo "ldd failed for $binary_file" - cat "$package_dir/.ldd.out" - missing=1 - continue - fi - # Report missing dependencies. - if grep -q "not found" "$package_dir/.ldd.out"; then - echo "Missing runtime dependencies for $binary_file" - grep "not found" "$package_dir/.ldd.out" - missing=1 - fi - done < <(find "$package_dir" -type f \( -perm /111 -o -name "*.so" -o -name "*.so.*" \)) - rm -f "$package_dir/.ldd.out" - # TODO: should error? - if [ "$missing" -ne 0 ]; then - echo "Runtime dependency check found issues; continuing packaging." - fi - return 0 - } - - # Iterate over all built Python wrappers in `install/ifcopenshell/python-x.y.z`. - # and zip them, bundling all dynamic libs from `lib`. - ls -d python-* | while read py_version; do - postfix=`echo ${py_version: -1} | sed s/[0-9]//` - numbers=`echo $py_version | grep -oE '[0-9]+\.[0-9]+' | tr -d '.'` - py_version_major=python-${numbers}$postfix - pushd . > /dev/null - cd $py_version - if [ ! -d ifcopenshell ]; then - mkdir ../ifcopenshell_ - mv * ../ifcopenshell_ - mv ../ifcopenshell_ ifcopenshell - fi - [ -d ifcopenshell/__pycache__ ] && rm -rf ifcopenshell/__pycache__ - find ifcopenshell -name "*.pyc" -delete - # TODO: packs qt libs also? - stage_runtime_payload ifcopenshell - zip -y -r -qq ifcopenshell-${py_version_major}-${VERSION}-${GITHUB_SHA:0:7}-linux64.zip ifcopenshell - mv *.zip ~/output - popd > /dev/null - done - - # Iterate over all executables in `install/ifcopenshell/bin` and zip them. - # Each zip bundles dynamic libs from `lib` and also qt libs. - find "$install_root/bin" -maxdepth 1 -type f -perm /111 ! -name "*.zip" ! -name "*.so" ! -name "*.so.*" ! -name "*.dylib" ! -name "*.dll" | while read exe_path; do - exe=`basename "$exe_path"` - package_dir="$install_root/.package-${exe}" - rm -rf "$package_dir" - mkdir -p "$package_dir" - cp "$exe_path" "$package_dir/" - patchelf --set-rpath '$ORIGIN' "$package_dir/$exe" - stage_runtime_payload "$package_dir" 0 - stage_qt_runtime_payload "$exe_path" "$package_dir" - if [ "$exe" = "BonsaiViewer" ]; then - mkdir -p "$package_dir/connectors" - cp -a "$autodesk_connector_dir" "$package_dir/connectors/" - fi - check_runtime_dependencies "$package_dir" - pushd "$package_dir" > /dev/null - zip -y -qq -r "$HOME/output/${exe}-${VERSION}-${GITHUB_SHA:0:7}-linux64.zip" . - popd > /dev/null - rm -rf "$package_dir" - done + uv run nix/package-zip-archives.py linux64 - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v6 diff --git a/.github/workflows/build_rocky_arm.yml b/.github/workflows/build_rocky_arm.yml index b805d81436..4610d83ff0 100644 --- a/.github/workflows/build_rocky_arm.yml +++ b/.github/workflows/build_rocky_arm.yml @@ -122,148 +122,7 @@ jobs: - name: Package .zip archives shell: bash run: | - VERSION=v`cat VERSION` - # bonsaiviewer-autodesk is now a Rust connector. packaging/build.py - # invokes `cargo build --release` and stages the binary + - # connector.json into dist/autodesk/. Same on-disk shape as the - # old PyInstaller flow so the symlink + zip steps below - # continue to work unchanged. - uv run src/bonsaiviewer-autodesk/packaging/build.py - autodesk_connector_dir="$PWD/src/bonsaiviewer-autodesk/dist/autodesk" - test -d "$autodesk_connector_dir" - - cd ./build/`uname`/*/install/ifcopenshell - mkdir -p ~/output - install_root="$PWD" - QT6_VERSION="${QT6_VERSION:-6.8.3}" - - if [ -z "${QT_DIR:-}" ]; then - for qt_candidate in "$(dirname "$install_root")"/qt6-${QT6_VERSION}-*/${QT6_VERSION}/*; do - if [ -d "$qt_candidate/lib" ]; then - QT_DIR="$qt_candidate" - break - fi - done - fi - - ensure_soname_links() { - dest="$1" - find "$dest" -maxdepth 1 -type f -name "*.so*" | while IFS= read -r shared_object; do - soname=$(readelf -d "$shared_object" 2>/dev/null | sed -n 's/.*(SONAME).*Shared library: \[\(.*\)\].*/\1/p' | head -n 1) - [ -n "$soname" ] || continue - [ -e "$dest/$soname" ] && continue - ln -s "$(basename "$shared_object")" "$dest/$soname" - done - } - - stage_runtime_payload() { - dest="$1" - include_geometry_writers="${2:-1}" - while IFS= read -r runtime_file; do - if [ "$include_geometry_writers" != "1" ] && [[ "$(basename "$runtime_file")" == ifcopenshell.geometry.writer.* ]]; then - continue - fi - cp -P "$runtime_file" "$dest/" - done < <( - for runtime_dir in "$install_root/bin" "$install_root/lib" "$install_root/lib64"; do - [ -d "$runtime_dir" ] || continue - find "$runtime_dir" \( -type f -o -type l \) \( -name "*.so" -o -name "*.so.*" -o -name "*.dylib" -o -name "*.dll" \) - done - ) - ensure_soname_links "$dest" - } - - stage_qt_runtime_payload() { - exe_path="$1" - dest="$2" - [ -n "${QT_DIR:-}" ] && [ -d "$QT_DIR/lib" ] || return 0 - - if ! LD_LIBRARY_PATH="$QT_DIR/lib:${LD_LIBRARY_PATH:-}" ldd "$exe_path" 2>/dev/null | grep -q "libQt6"; then - return 0 - fi - - find "$QT_DIR/lib" -maxdepth 1 \( -type f -o -type l \) -name "*.so*" -exec cp -P {} "$dest/" \; - ensure_soname_links "$dest" - - if [ -d "$QT_DIR/plugins" ]; then - pushd "$QT_DIR/plugins" > /dev/null - find . \( -type f -o -type l \) -name "*.so*" | while IFS= read -r plugin_file; do - mkdir -p "$dest/plugins/$(dirname "$plugin_file")" - cp -P "$plugin_file" "$dest/plugins/$plugin_file" - done - popd > /dev/null - if [ -d "$dest/plugins" ]; then - find "$dest/plugins" -type f -name "*.so*" -exec patchelf --set-rpath '$ORIGIN/../..:$ORIGIN' {} \; - fi - fi - - find "$dest" -maxdepth 1 -type f -name "*.so*" -exec patchelf --set-rpath '$ORIGIN' {} \; - - printf "[Paths]\nPrefix = .\n" > "$dest/qt.conf" - } - - check_runtime_dependencies() { - package_dir="$1" - missing=0 - while IFS= read -r binary_file; do - readelf -h "$binary_file" >/dev/null 2>&1 || continue - if ! env -u LD_LIBRARY_PATH ldd "$binary_file" > "$package_dir/.ldd.out" 2>&1; then - echo "ldd failed for $binary_file" - cat "$package_dir/.ldd.out" - missing=1 - continue - fi - if grep -q "not found" "$package_dir/.ldd.out"; then - echo "Missing runtime dependencies for $binary_file" - grep "not found" "$package_dir/.ldd.out" - missing=1 - fi - done < <(find "$package_dir" -type f \( -perm /111 -o -name "*.so" -o -name "*.so.*" \)) - rm -f "$package_dir/.ldd.out" - if [ "$missing" -ne 0 ]; then - echo "Runtime dependency check found issues; continuing packaging." - fi - return 0 - } - - ls -d python-* | while read py_version; do - postfix=`echo ${py_version: -1} | sed s/[0-9]//` - numbers=`echo $py_version | grep -oE '[0-9]+\.[0-9]+' | tr -d '.'` - py_version_major=python-${numbers}$postfix - pushd . > /dev/null - cd $py_version - if [ ! -d ifcopenshell ]; then - mkdir ../ifcopenshell_ - mv * ../ifcopenshell_ - mv ../ifcopenshell_ ifcopenshell - fi - [ -d ifcopenshell/__pycache__ ] && rm -rf ifcopenshell/__pycache__ - find ifcopenshell -name "*.pyc" -delete - stage_runtime_payload ifcopenshell - zip -y -r -qq ifcopenshell-${py_version_major}-${VERSION}-${GITHUB_SHA:0:7}-linuxarm64.zip ifcopenshell - mv *.zip ~/output - popd > /dev/null - done - - find "$install_root/bin" -maxdepth 1 -type f -perm /111 ! -name "*.zip" ! -name "*.so" ! -name "*.so.*" ! -name "*.dylib" ! -name "*.dll" | while read exe_path; do - exe=`basename "$exe_path"` - package_dir="$install_root/.package-${exe}" - rm -rf "$package_dir" - mkdir -p "$package_dir" - cp "$exe_path" "$package_dir/" - patchelf --set-rpath '$ORIGIN' "$package_dir/$exe" - stage_runtime_payload "$package_dir" 0 - stage_qt_runtime_payload "$exe_path" "$package_dir" - if [ "$exe" = "BonsaiViewer" ]; then - mkdir -p "$package_dir/connectors" - cp -a "$autodesk_connector_dir" "$package_dir/connectors/" - fi - check_runtime_dependencies "$package_dir" - pushd "$package_dir" > /dev/null - zip -y -qq -r "$HOME/output/${exe}-${VERSION}-${GITHUB_SHA:0:7}-linuxarm64.zip" . - popd > /dev/null - rm -rf "$package_dir" - done + uv run nix/package-zip-archives.py linuxarm64 - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v6 diff --git a/nix/package-zip-archives.py b/nix/package-zip-archives.py new file mode 100644 index 0000000000..e4d2e12bd3 --- /dev/null +++ b/nix/package-zip-archives.py @@ -0,0 +1,346 @@ +#!/usr/bin/env -S uv run --script +# /// script +# /// + +import argparse +import os +import platform +import re +import shlex +import shutil +import subprocess +from pathlib import Path +from typing import Literal + + +def run( + *cmd: str, + cwd: Path | None = None, + env: dict[str, str] | None = None, + stderr: int | None = None, +) -> str: + print("$", shlex.join(cmd)) + return subprocess.check_output(cmd, cwd=cwd, env=env, stderr=stderr, text=True) + + +REPO_ROOT = Path(run("git", "-C", str(Path(__file__).parent), "rev-parse", "--show-toplevel").strip()) +VERSION = "v" + (REPO_ROOT / "VERSION").read_text().strip() + + +def is_platform(name: Literal["MAC", "LINUX"]) -> bool: + current = "MAC" if platform.system() == "Darwin" else "LINUX" + return current == name + + +def get_install_dir() -> Path: + if is_platform("MAC"): + pattern = "Darwin/*/*/install" + else: + pattern = "*/*/install" + for data in (REPO_ROOT / "build").glob(pattern): + return data + raise Exception("No install dir found") + + +def find_qt_dir(install_root: Path, qt6_version: str) -> Path | None: + for qt_candidate in install_root.glob(f"qt6-{qt6_version}-*/{qt6_version}/*"): + if (qt_candidate / "lib").is_dir(): + return qt_candidate + return None + + +def ensure_soname_links(dest: Path) -> None: + """Ensure that all shared libraries in `dest` are present using their SONAMEs (at least as symlinks).""" + for shared_object in dest.glob("*.so*"): + if not shared_object.is_file(): + continue + try: + readelf_output = run("readelf", "-d", str(shared_object)) + except subprocess.CalledProcessError: + continue + # TODO: actual pattern is "Library soname" instead of "Shared library"? + match = re.search(r"\(SONAME\).*Shared library: \[(.*)\]", readelf_output) + if not match: + continue + soname = match.group(1) + soname_path = dest / soname + if soname_path.exists(): + continue + soname_path.symlink_to(shared_object.name) + + +def is_shared_library(path: Path) -> bool: + name = path.name.lower() + return name.endswith((".so", ".dylib", ".dll")) or ".so." in name + + +def stage_runtime_payload(ifcopenshell_install_dir: Path, dest: Path, *, include_geometry_writers: bool = True) -> None: + """Copy all libs from `ifcopenshell_install_dir/{bin,lib,lib64}` into `dest`.""" + for runtime_dir_name in ("bin", "lib", "lib64"): + runtime_dir = ifcopenshell_install_dir / runtime_dir_name + if not runtime_dir.is_dir(): + continue + for runtime_file in runtime_dir.rglob("*"): + if not (runtime_file.is_symlink() or runtime_file.is_file()): + continue + if not is_shared_library(runtime_file): + continue + if not include_geometry_writers and runtime_file.name.startswith("ifcopenshell.geometry.writer."): + continue + shutil.copy(runtime_file, dest / runtime_file.name, follow_symlinks=False) + if not is_platform("MAC"): + ensure_soname_links(dest) + + +def stage_qt_runtime_payload(exe_path: Path, dest: Path, qt_dir: Path | None) -> None: + """Copy QT libs/plugins from `qt_dir` next to `exe_path`, if it depends on QT.""" + + def is_so_file(path: Path) -> bool: + return (path.is_file() or path.is_symlink()) and ".so" in path.name + + if not qt_dir or not (qt_dir / "lib").is_dir(): + return + + # Skip executables that don't depend on QT (don't have `libQt6` referenced). + env = os.environ.copy() + env["LD_LIBRARY_PATH"] = f"{qt_dir / 'lib'}:{env.get('LD_LIBRARY_PATH', '')}" + try: + ldd_output = run("ldd", str(exe_path), env=env) + except subprocess.CalledProcessError: + return + if "libQt6" not in ldd_output: + return + + # Copy all QT libs to `dest`. + for lib_file in (qt_dir / "lib").iterdir(): + if is_so_file(lib_file): + shutil.copy(lib_file, dest / lib_file.name, follow_symlinks=False) + ensure_soname_links(dest) + + # Copy QT plugins. + plugins_dir = qt_dir / "plugins" + if plugins_dir.is_dir(): + for plugin_file in plugins_dir.rglob("*"): + if not is_so_file(plugin_file): + continue + dest_plugin_file = dest / "plugins" / plugin_file.relative_to(plugins_dir) + dest_plugin_file.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(plugin_file, dest_plugin_file, follow_symlinks=False) + + # Point plugins rpath to `dest`. + dest_plugins_dir = dest / "plugins" + if dest_plugins_dir.is_dir(): + for plugin_so in dest_plugins_dir.rglob("*.so*"): + if plugin_so.is_file(): + run("patchelf", "--set-rpath", "$ORIGIN/../..:$ORIGIN", str(plugin_so)) + + # Non-recursive, set rpath only for top-level libs. + for lib_so in dest.glob("*.so*"): + if lib_so.is_file(): + run("patchelf", "--set-rpath", "$ORIGIN", str(lib_so)) + + qt_conf_path = dest / "qt.conf" + qt_conf_path.write_text("[Paths]\nPrefix = .\n") + + +def check_runtime_dependencies(package_dir: Path) -> None: + """Check all binaries in `package_dir` and report if they're still missing dependencies or are static.""" + + def is_executable_or_so(path: Path) -> bool: + name = path.name + return os.access(path, os.X_OK) or name.endswith(".so") or ".so." in name + + missing = False + env = os.environ.copy() + env.pop("LD_LIBRARY_PATH", None) + + for binary_file in package_dir.rglob("*"): + if not binary_file.is_file() or not is_executable_or_so(binary_file): + continue + + # Skip non-binaries. + try: + run("readelf", "-h", str(binary_file), stderr=subprocess.DEVNULL) + except subprocess.CalledProcessError: + continue + + try: + ldd_output = run("ldd", str(binary_file), env=env, stderr=subprocess.STDOUT) + except subprocess.CalledProcessError as e: + print(f"ldd failed for {binary_file}") + print(e.output, end="") + missing = True + continue + + if "not found" in ldd_output: + print(f"Missing runtime dependencies for {binary_file}") + for line in ldd_output.splitlines(): + if "not found" in line: + print(line) + missing = True + + # TODO: should error? + if missing: + print("Runtime dependency check found issues; continuing packaging.") + + +def package_python_wrapper( + py_dir: Path, + ifcopenshell_install_dir: Path, + github_sha: str, + output_dir: Path, + arch_suffix: str, +) -> None: + py_version = py_dir.name + postfix = "" if py_version[-1].isdigit() else py_version[-1] + # Match and convert `x.y` -> `xy`. + version_match = re.search(r"[0-9]+\.[0-9]+", py_version) + assert version_match + numbers = "".join(version_match.group().split(".")) + py_version_major = f"python-{numbers}{postfix}" + + ifcopenshell_dir = py_dir / "ifcopenshell" + staging_dir = py_dir.parent / "ifcopenshell_" + staging_dir.mkdir() + for item in list(py_dir.iterdir()): + shutil.move(str(item), str(staging_dir)) + staging_dir.rename(ifcopenshell_dir) + + # Cache from test run during build. + pycache_dir = ifcopenshell_dir / "__pycache__" + if pycache_dir.is_dir(): + shutil.rmtree(pycache_dir) + for pyc_file in ifcopenshell_dir.rglob("*.pyc"): + pyc_file.unlink() + + # TODO: packs qt libs also? + stage_runtime_payload(ifcopenshell_install_dir, ifcopenshell_dir) + + zip_name = f"ifcopenshell-{py_version_major}-{VERSION}-{github_sha}-{arch_suffix}.zip" + run("zip", "-y", "-r", "-qq", zip_name, "ifcopenshell", cwd=py_dir) + shutil.move(str(py_dir / zip_name), str(output_dir / zip_name)) + + +def is_packageable_executable(path: Path) -> bool: + if not path.is_file() or not os.access(path, os.X_OK): + return False + return not (path.name.lower().endswith(".zip") or is_shared_library(path)) + + +def package_executable( + exe_path: Path, + ifcopenshell_install_dir: Path, + github_sha: str, + output_dir: Path, + autodesk_connector_dir: Path, + qt_dir: Path | None, + arch_suffix: str, +) -> None: + exe = exe_path.name + package_dir = ifcopenshell_install_dir / f".package-{exe}" + package_dir.mkdir(parents=True) + + shutil.copy(exe_path, package_dir / exe) + # TODO: kept `is_platform(MAC)` to retain original bash script behaviour, + # but is this guard needed or it should be always False? + stage_runtime_payload(ifcopenshell_install_dir, package_dir, include_geometry_writers=is_platform("MAC")) + + # On macOS, rpath is already set at build time via CMake's INSTALL_RPATH, and + # QT apps are packaged as .app bundles (`package_app_bundle`) instead. + if not is_platform("MAC"): + run("patchelf", "--set-rpath", "$ORIGIN", str(package_dir / exe)) + stage_qt_runtime_payload(exe_path, package_dir, qt_dir) + + if exe == "BonsaiViewer": + connectors_dir = package_dir / "connectors" + connectors_dir.mkdir() + shutil.copytree(autodesk_connector_dir, connectors_dir / autodesk_connector_dir.name, symlinks=True) + + check_runtime_dependencies(package_dir) + + zip_path = output_dir / f"{exe}-{VERSION}-{github_sha}-{arch_suffix}.zip" + run("zip", "-y", "-qq", "-r", str(zip_path), ".", cwd=package_dir) + shutil.rmtree(package_dir) + + +def package_app_bundle( + app_path: Path, + install_root: Path, + github_sha: str, + output_dir: Path, + autodesk_connector_dir: Path, + arch_suffix: str, +) -> None: + """Zip a `.app` bundle (e.g. BonsaiViewer.app) living at the install-prefix root. + + Their install rule uses `BUNDLE DESTINATION "."` - that's the layout Qt's + macdeployqt expects. macdeployqt has already embedded the Qt frameworks + inside each bundle during install/strip, so the only thing left to stage + is the connector. + """ + app = app_path.stem + + if app == "BonsaiViewer": + # ConnectorDiscovery looks in applicationDirPath()/connectors, + # which for a bundle is Contents/MacOS. + connectors_dir = app_path / "Contents" / "MacOS" / "connectors" + connectors_dir.mkdir(parents=True) + shutil.copytree(autodesk_connector_dir, connectors_dir / autodesk_connector_dir.name, symlinks=True) + + zip_path = output_dir / f"{app}-{VERSION}-{github_sha}-{arch_suffix}.zip" + run("zip", "-qq", "-r", str(zip_path), app_path.name, cwd=install_root) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("arch_suffix", help="Zip filename suffix, e.g. linux64 or linuxarm64.") + args = parser.parse_args() + + # bonsaiviewer-autodesk is now a Rust connector. packaging/build.py + # invokes `cargo build --release` and stages the binary + + # connector.json into dist/autodesk/. Same on-disk shape as the + # old PyInstaller flow so the symlink + zip steps below + # continue to work unchanged. + run("uv", "run", str(REPO_ROOT / "src/bonsaiviewer-autodesk/packaging/build.py")) + autodesk_connector_dir = REPO_ROOT / "src/bonsaiviewer-autodesk/dist/autodesk" + assert autodesk_connector_dir.is_dir() + + # Locate the ifcopenshell install dir and stage QT6 alongside the zip output. + install_root = get_install_dir() + ifcopenshell_install_dir = install_root / "ifcopenshell" + + output_dir = Path.home() / "output" + output_dir.mkdir(parents=True, exist_ok=True) + + qt6_version = os.getenv("QT6_VERSION", "6.8.3") + qt_dir_env = os.getenv("QT_DIR") + qt_dir = Path(qt_dir_env) if qt_dir_env else find_qt_dir(install_root, qt6_version) + + # Iterate over all built Python wrappers in `install/ifcopenshell/python-x.y.z` + # and zip them, bundling all dynamic libs from `lib`. + github_sha = os.environ["GITHUB_SHA"][:7] + for py_dir in sorted(ifcopenshell_install_dir.glob("python-*")): + package_python_wrapper(py_dir, ifcopenshell_install_dir, github_sha, output_dir, args.arch_suffix) + + # Iterate over all executables in `install/ifcopenshell/bin` and zip them. + # Each zip bundles dynamic libs from `lib` and also qt libs. + bin_dir = ifcopenshell_install_dir / "bin" + for exe_path in sorted(bin_dir.iterdir()): + if is_packageable_executable(exe_path): + package_executable( + exe_path, + ifcopenshell_install_dir, + github_sha, + output_dir, + autodesk_connector_dir, + qt_dir, + args.arch_suffix, + ) + + if is_platform("MAC"): + for app_path in sorted(install_root.glob("*.app")): + package_app_bundle(app_path, install_root, github_sha, output_dir, autodesk_connector_dir, args.arch_suffix) + + +if __name__ == "__main__": + main()