diff --git a/vcv/DISTRIBUTION.md b/vcv/DISTRIBUTION.md index a96e6d6..5a6c8a7 100644 --- a/vcv/DISTRIBUTION.md +++ b/vcv/DISTRIBUTION.md @@ -48,20 +48,33 @@ CPUS=4 MEM=6g vcv/build-win.sh # tighter caps Note: the OSC bridge server needs Winsock on Windows; `vcv/Makefile` links `-lws2_32` when `ARCH_WIN` is set (mingw ignores the MSVC `#pragma comment(lib,…)`). -### macOS (x64 + arm64) — Apple SDK required +### macOS (x64 + arm64) — locally, resource-bounded (works today) -macOS cross-builds need Apple's **licensed macOS SDK**, which we never download or -redistribute on this host. Two routes: +`vcv/build-mac.sh` cross-builds both macOS arches in a **hard-capped Docker container** +(`--cpus=8 --memory=20g --memory-swap=20g`, no host swap), ad-hoc signs the dylibs with +`rcodesign`, and writes `vcv/dist/MEMLNaut--mac-{x64,arm64}.vcvplugin`. -1. **CI** (`.github/workflows/vcv-plugin.yml`) — the - [VCVRack/rack-plugin-toolchain](https://github.com/VCVRack/rack-plugin-toolchain) - builds mac-x64 + mac-arm64 on tagged releases (the toolchain fetches the SDK only - inside its own image build). -2. **A Mac** — build with the native SDK + `RACK_DIR` set to the macOS Rack SDK. +```bash +vcv/build-mac.sh # first run ~18 min (builds the osxcross toolchain image) +vcv/build-mac.sh # reruns ~2.5 min (cached image) +REBUILD_TOOLCHAIN=1 vcv/build-mac.sh # force-rebuild the toolchain image +``` -The full rack-plugin-toolchain image build is **not** run on this host: its Dockerfile -`COPY`s the Apple SDK and compiles osxcross, so it both needs the SDK and is multi-GB -/ multi-hour. That belongs on CI. (Linux + Windows above need none of it.) +Key choices that keep it bounded + legal-ish: +- **osxcross is built with the container's SYSTEM clang** — we do NOT compile + Clang/LLVM from source (the official toolchain's `build_clang.sh` is the multi-hour, + RAM-hungry trap). osxcross only builds cctools-port + ld64 + wrappers (~16-18 min, + peak < 1 GB RAM). The result is cached as a local Docker image + `nisps-osxcross:darwin21.4-12.3` (~3 GB, contains the SDK) so reruns are fast. +- **Apple macOS SDK**: `MacOSX12.3.sdk.tar.xz` from + [joseluisq/macosx-sdks](https://github.com/joseluisq/macosx-sdks). Apple's licence + restricts SDK use to Apple hardware — this is a grey area; it never leaves the local + toolchain image and is never redistributed. +- The dylibs are **ad-hoc signed, not notarised**, so macOS Gatekeeper may quarantine a + download. Users clear it once: `xattr -dr com.apple.quarantine ~/Documents/Rack2/plugins-mac-*/MEMLNaut`. + +CI (below) remains an alternative for macOS — and the only option if you don't want the +SDK on this host — but the local bounded build is the default now (no dedicated box). --- diff --git a/vcv/build-mac.sh b/vcv/build-mac.sh new file mode 100755 index 0000000..ac893cd --- /dev/null +++ b/vcv/build-mac.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +# +# build-mac.sh — cross-build the MEMLNaut VCV plugin for macOS (x64 + arm64), +# here, in a RESOURCE-BOUNDED Docker container. +# +# Why Docker + caps: this host also runs live production services. The container +# is hard-capped (CPU + memory + no extra swap) so the build physically cannot +# starve or crash the host — the cgroup OOM-kills the container, not the system, +# if it ever exceeded the cap. Tune CPUS / MEM below for your machine. +# +# How (crucially): we do NOT compile Clang/LLVM from source. The official +# rack-plugin-toolchain runs build_clang.sh (LLVM from source = multi-hour, +# RAM-hungry — FORBIDDEN here). Instead we build osxcross with the container's +# SYSTEM clang (apt install clang lld); osxcross then only needs to build +# cctools-port + ld64 + wrappers, which is moderate and bounded (~15-20 min). +# +# That osxcross toolchain is cached as a local Docker image +# (nisps-osxcross:-) on first build, so subsequent runs skip +# straight to compiling the plugin (~2 min). Set REBUILD_TOOLCHAIN=1 to force a +# fresh osxcross build. +# +# Apple's macOS SDK is fetched from joseluisq/macosx-sdks (MacOSX12.3.sdk) only +# inside the container, for the duration of the toolchain build. We never +# redistribute it; it lives only inside the local cache image. +# +# Output: vcv/dist/MEMLNaut--mac-x64.vcvplugin +# vcv/dist/MEMLNaut--mac-arm64.vcvplugin (both ad-hoc signed) +# Requires: docker. Run from anywhere; paths are resolved from this script. + +set -euo pipefail + +CPUS="${CPUS:-8}" # cores the container may use +MEM="${MEM:-20g}" # hard memory cap (== memory-swap, so no host swap) +RACK_SDK_VERSION="${RACK_SDK_VERSION:-2.6.4}" +OSXCROSS_COMMIT="${OSXCROSS_COMMIT:-4372d5560307c649af5dbbfa20b39199c9ef48be}" # same pin as rack-plugin-toolchain +MACOS_SDK_VERSION="${MACOS_SDK_VERSION:-12.3}" +DARWIN_TARGET="darwin21.4" # macOS 12.x = Darwin 21 +TOOLCHAIN_IMAGE="${TOOLCHAIN_IMAGE:-nisps-osxcross:${DARWIN_TARGET}-${MACOS_SDK_VERSION}}" +REBUILD_TOOLCHAIN="${REBUILD_TOOLCHAIN:-0}" + +CAPS=(--cpus="$CPUS" --memory="$MEM" --memory-swap="$MEM") # the mandatory hard caps + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # vcv/ + +# --------------------------------------------------------------------------- +# Stage 1: osxcross toolchain image (built once, cached). Uses the container's +# SYSTEM clang — never build_clang.sh / LLVM-from-source. +# --------------------------------------------------------------------------- +build_toolchain_image () { + echo "==> building osxcross toolchain image $TOOLCHAIN_IMAGE (cpus=$CPUS, mem=$MEM, no extra swap)" + echo " cctools-port + ld64 + wrappers with SYSTEM clang. May take 15-20 min." + local TC="$(mktemp /tmp/osxcross-stage.XXXXXX.sh)" + cat > "$TC" < [tc] installing build deps (system clang + lld; NO LLVM-from-source)" +apt-get update -qq +apt-get install -y -qq \ + clang lld llvm-dev libxml2-dev uuid-dev libssl-dev zlib1g-dev libbz2-dev \ + cmake make python3 git patch xz-utils bzip2 curl unzip zstd jq tar wget rsync \ + >/dev/null +cd /tmp +echo "==> [tc] cloning osxcross @ ${OSXCROSS_COMMIT}" +git clone -q https://github.com/tpoechtrager/osxcross.git +cd osxcross +git checkout -q ${OSXCROSS_COMMIT} +echo "==> [tc] fetching macOS ${MACOS_SDK_VERSION} SDK" +wget -q -O tarballs/MacOSX${MACOS_SDK_VERSION}.sdk.tar.xz \ + "https://github.com/joseluisq/macosx-sdks/releases/download/${MACOS_SDK_VERSION}/MacOSX${MACOS_SDK_VERSION}.sdk.tar.xz" +echo "==> [tc] building osxcross (UNATTENDED, system clang, no build_clang.sh)" +UNATTENDED=1 TARGET_DIR=/opt/osxcross JOBS=${CPUS} ./build.sh +echo "==> [tc] building compiler-rt (best-effort)" +UNATTENDED=1 TARGET_DIR=/opt/osxcross JOBS=${CPUS} ENABLE_COMPILER_RT_INSTALL=1 \ + ./build_compiler_rt.sh || echo " (compiler-rt skipped; continuing)" +echo "==> [tc] osxcross wrappers:" +ls /opt/osxcross/bin/ | grep -E "apple-${DARWIN_TARGET}-(clang|strip)" || true +echo "OSXCROSS_TOOLCHAIN_READY" +EOF + trap 'rm -f "$TC"' RETURN + docker rm -f nisps-osxcross-stage >/dev/null 2>&1 || true + docker run --name nisps-osxcross-stage "${CAPS[@]}" \ + -v "$TC":/stage.sh:ro ubuntu:24.04 bash /stage.sh + echo "==> committing toolchain image $TOOLCHAIN_IMAGE" + docker commit nisps-osxcross-stage "$TOOLCHAIN_IMAGE" >/dev/null + docker rm -f nisps-osxcross-stage >/dev/null 2>&1 || true +} + +if [ "$REBUILD_TOOLCHAIN" = "1" ] || ! docker image inspect "$TOOLCHAIN_IMAGE" >/dev/null 2>&1; then + build_toolchain_image +else + echo "==> reusing cached osxcross toolchain image $TOOLCHAIN_IMAGE (set REBUILD_TOOLCHAIN=1 to rebuild)" +fi + +# --------------------------------------------------------------------------- +# Stage 2: build (and ad-hoc sign) the plugin for both mac arches, against the +# cached toolchain image. Staged in a clean copy of vcv/ so artefacts never +# pollute the repo tree. +# --------------------------------------------------------------------------- +WORK="$(mktemp -d /tmp/vcv-mac-build.XXXXXX)" +trap 'rm -rf "$WORK"' EXIT + +echo "==> staging a clean copy of the plugin source" +cp -r "$SCRIPT_DIR/." "$WORK/" +rm -rf "$WORK/build" "$WORK/dist" "$WORK/out" "$WORK/plugin.so" "$WORK"/*.vcvplugin 2>/dev/null || true + +cat > "$WORK/_in-container.sh" < [container] fetching apple-codesign (rcodesign) for ad-hoc signing" +RC_VER="0.27.0" +RC_URL="https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F\${RC_VER}/apple-codesign-\${RC_VER}-x86_64-unknown-linux-musl.tar.gz" +if wget -q -O /tmp/rcodesign.tar.gz "\$RC_URL"; then + mkdir -p /tmp/rc && tar -xzf /tmp/rcodesign.tar.gz -C /tmp/rc --strip-components=1 2>/dev/null || \ + tar -xzf /tmp/rcodesign.tar.gz -C /tmp/rc 2>/dev/null || true + RCODESIGN="\$(find /tmp/rc -name rcodesign -type f | head -1)" + if [ -n "\$RCODESIGN" ]; then chmod +x "\$RCODESIGN"; echo " rcodesign: \$RCODESIGN"; fi +fi +[ -z "\$RCODESIGN" ] && echo " (rcodesign unavailable; will skip ad-hoc signing)" + +build_arch () { + local PLAT="\$1" # mac-x64 | mac-arm64 + local SDK_ARCH="\$2" # x64 | arm64 + local TRIPLE="\$3" # x86_64-apple-${DARWIN_TARGET} | arm64-apple-${DARWIN_TARGET} + local ARCH_FLAG="\$4" # x86_64 | arm64 (passed to clang -arch) + + echo "==> [container] === building \$PLAT ===" + cd /tmp + rm -rf "Rack-SDK" + wget -q "https://vcvrack.com/downloads/Rack-SDK-${RACK_SDK_VERSION}-mac-\${SDK_ARCH}.zip" + unzip -q "Rack-SDK-${RACK_SDK_VERSION}-mac-\${SDK_ARCH}.zip" + rm -f "Rack-SDK-${RACK_SDK_VERSION}-mac-\${SDK_ARCH}.zip" + + cd /src + # Wipe all build products between arches explicitly. 'make clean' is unreliable + # here because the Makefile's 'include \$(RACK_DIR)/arch.mk' needs RACK_DIR set + # just to parse — without it clean silently no-ops, leaving the previous arch's + # plugin.dylib in place so 'make dist' skips recompilation and ships the WRONG + # architecture (x86_64 in the arm64 package). Remove the products outright. + rm -rf build dist plugin.dylib plugin.so 2>/dev/null || true + find . -maxdepth 3 \( -name '*.o' -o -name '*.d' \) -delete 2>/dev/null || true + + # The mac Rack SDK's plugin.mk dist target calls Apple-only tools by bare name: + # \$(STRIP) \$(INSTALL_NAME_TOOL) \$(OTOOL) and (unconditionally for mac) \$(CODESIGN). + # osxcross provides the first three as -prefixed binaries; point the make + # vars at them. CODESIGN we override to a no-op (true) and instead ad-hoc sign with + # rcodesign below — the SDK's default 'codesign -f -s -' is a native-macOS binary + # that does not exist on Linux (was Error 127). rsync is needed for the res/ copy. + # + # arch.mk derives the target arch from \`\$(CC) -dumpmachine\`. The osxcross clang + # wrappers are all symlinks to one binary, so -dumpmachine reports the host default + # (x86_64) regardless of the prefix — which made arch.mk pick the x64 path + # (-march=nehalem) even for the arm64 wrapper, silently producing an x86_64 dylib in + # the arm64 package. Pass CROSS_COMPILE= so arch.mk classifies correctly + # (-> ARCH_ARM64, -march=armv8-a) and append an explicit -arch to CC/CXX so the + # wrapper emits the right Mach-O architecture. + export MACOSX_DEPLOYMENT_TARGET=10.15 + RACK_DIR=/tmp/Rack-SDK \ + CROSS_COMPILE="\${TRIPLE}" \ + CC="\${TRIPLE}-clang -arch \${ARCH_FLAG}" \ + CXX="\${TRIPLE}-clang++ -arch \${ARCH_FLAG}" \ + STRIP="\${TRIPLE}-strip" \ + INSTALL_NAME_TOOL="\${TRIPLE}-install_name_tool" \ + OTOOL="\${TRIPLE}-otool" \ + CODESIGN=true \ + make -j${CPUS} dist + + # Ad-hoc sign the dylib inside the produced .vcvplugin, if rcodesign is available. + local PKG="\$(ls dist/MEMLNaut-*-\${PLAT}.vcvplugin)" + local BASENAME="\$(basename "\$PKG")" + if [ -n "\$RCODESIGN" ] && [ -n "\$PKG" ]; then + echo "==> [container] ad-hoc signing dylib in \$PKG" + local TMP=/tmp/sign-\${PLAT} + rm -rf "\$TMP" && mkdir -p "\$TMP" + tar --use-compress-program=unzstd -xf "\$PKG" -C "\$TMP" + local DYLIB="\$(find "\$TMP" -name plugin.dylib | head -1)" + if [ -n "\$DYLIB" ]; then + "\$RCODESIGN" sign "\$DYLIB" && echo " signed: \$DYLIB" + # \$PKG is relative to /src; resolve to absolute before the cd into \$TMP. + ( cd "\$TMP" && tar --use-compress-program=zstd -cf "/src/\$PKG" MEMLNaut ) + echo "SIGNED_\${PLAT}=yes" >> /src/out/_sign_report.txt + fi + else + echo "SIGNED_\${PLAT}=no" >> /src/out/_sign_report.txt + fi + + # Stash the (signed) artefact in /src/out/ BEFORE the next arch's 'make clean' + # wipes dist/ — clean runs 'rm -rf dist', which would otherwise delete this file. + cp "\$PKG" /src/out/ + echo "==> [container] verifying \$BASENAME architecture (cputype: 16777223=x86_64, 16777228=arm64):" + "\${TRIPLE}-otool" -h "\$TMP/MEMLNaut/plugin.dylib" 2>/dev/null | sed -n '3,4p' || true + ls -la "/src/out/\$BASENAME" +} + +mkdir -p /src/out +: > /src/out/_sign_report.txt +build_arch mac-x64 x64 "x86_64-apple-${DARWIN_TARGET}" x86_64 +build_arch mac-arm64 arm64 "arm64-apple-${DARWIN_TARGET}" arm64 + +echo "==> [container] final artefacts:" +ls -la /src/out/*.vcvplugin +cat /src/out/_sign_report.txt + +# Hand the staged tree back to the invoking host user so the cleanup trap and +# dist copy work without root-owned leftovers. +chown -R \${HOST_UID}:\${HOST_GID} /src 2>/dev/null || true +EOF + +echo "==> building macOS plugins in a bounded container (cpus=$CPUS, mem=$MEM, no extra swap)" +free -h +docker run --rm "${CAPS[@]}" \ + -e RACK_SDK_VERSION="$RACK_SDK_VERSION" \ + -e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" \ + -v "$WORK:/src" "$TOOLCHAIN_IMAGE" bash /src/_in-container.sh +free -h + +mkdir -p "$SCRIPT_DIR/dist" +cp "$WORK"/out/*-mac-x64.vcvplugin "$SCRIPT_DIR/dist/" +cp "$WORK"/out/*-mac-arm64.vcvplugin "$SCRIPT_DIR/dist/" +echo "==> done:" +ls -la "$SCRIPT_DIR"/dist/*-mac-*.vcvplugin