diff --git a/.github/workflows/create_release.yml b/.github/workflows/create_release.yml index f2a84af977..07a7508f34 100644 --- a/.github/workflows/create_release.yml +++ b/.github/workflows/create_release.yml @@ -464,7 +464,9 @@ jobs: - name: Package CLI binary run: | mv ${{ steps.bundle_cli.outputs.binary_path }} oz-${{ steps.get-config.outputs.channel }} - tar czf oz-${{ steps.get-config.outputs.channel }}-macos-${{ matrix.arch }}.tar.gz oz-${{ steps.get-config.outputs.channel }} -C "$(dirname "${{ steps.bundle_cli.outputs.bundled_resources_dir }}")" resources + tar czf oz-${{ steps.get-config.outputs.channel }}-macos-${{ matrix.arch }}.tar.gz \ + oz-${{ steps.get-config.outputs.channel }} \ + -C "$(dirname "${{ steps.bundle_cli.outputs.bundled_resources_dir }}")" resources lib - name: Add CLI to GitHub release assets if: ${{ needs.prepare_release.outputs.should_publish == 'true' }} diff --git a/crates/remote_server/src/install_remote_server.sh b/crates/remote_server/src/install_remote_server.sh index 721ceaca7b..84dca018bc 100644 --- a/crates/remote_server/src/install_remote_server.sh +++ b/crates/remote_server/src/install_remote_server.sh @@ -55,6 +55,29 @@ cleanup() { } trap cleanup EXIT +install_lib_dir() { + extracted_lib_dir="$1" + staged_lib_dir="$install_dir/.lib.$(basename "$tmpdir")" + backup_lib_dir="$install_dir/.lib.previous.$(basename "$tmpdir")" + + rm -rf "$staged_lib_dir" "$backup_lib_dir" + mv "$extracted_lib_dir" "$staged_lib_dir" + + if [ -e "$install_dir/lib" ]; then + mv "$install_dir/lib" "$backup_lib_dir" + fi + + if mv "$staged_lib_dir" "$install_dir/lib"; then + rm -rf "$backup_lib_dir" + else + if [ -e "$backup_lib_dir" ]; then + mv "$backup_lib_dir" "$install_dir/lib" + fi + rm -rf "$staged_lib_dir" + exit 1 + fi +} + staging_tarball_path="{staging_tarball_path}" if [ -n "$staging_tarball_path" ]; then # SCP fallback: tarball already uploaded by the client. @@ -82,4 +105,7 @@ tar -xzf "$tmpdir/oz.tar.gz" -C "$tmpdir" bin=$(find "$tmpdir" -type f -name 'oz*' ! -name '*.tar.gz' | head -n1) if [ -z "$bin" ]; then echo "no binary found in tarball" >&2; exit 1; fi chmod +x "$bin" +if [ -d "$tmpdir/lib" ]; then + install_lib_dir "$tmpdir/lib" +fi mv "$bin" "$install_dir/{binary_name}{version_suffix}" diff --git a/crates/remote_server/src/setup_tests.rs b/crates/remote_server/src/setup_tests.rs index e9a238de57..3a806e95a3 100644 --- a/crates/remote_server/src/setup_tests.rs +++ b/crates/remote_server/src/setup_tests.rs @@ -366,6 +366,133 @@ fn install_script_avoids_pattern_substitution_for_tilde_expansion() { ); } +/// Regression: macOS CLI tarballs may include Swift runtime libraries +/// in a `lib/` sidecar next to the standalone binary. The installer +/// must preserve the binary's existing final path while installing the +/// sidecar at `$install_dir/lib` using the production shell script. +#[cfg(unix)] +#[test] +fn install_script_installs_lib_sidecar_without_changing_binary_path() { + use command::blocking::Command; + use std::{ + fs, + io::Write, + path::{Path, PathBuf}, + process::Stdio, + time::{SystemTime, UNIX_EPOCH}, + }; + + struct TempDir { + path: PathBuf, + } + + impl TempDir { + fn new() -> Self { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after UNIX epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "warp-remote-install-lib-sidecar-{}-{nanos}", + std::process::id() + )); + fs::create_dir_all(&path).expect("failed to create temp dir"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + let temp = TempDir::new(); + let fake_home = temp.path().join("home"); + let package_dir = temp.path().join("package"); + let lib_dir = package_dir.join("lib"); + fs::create_dir_all(&lib_dir).expect("failed to create package lib dir"); + + let binary_path = package_dir.join("oz-test"); + let mut binary = fs::File::create(&binary_path).expect("failed to create package binary"); + writeln!(binary, "#!/bin/sh").expect("failed to write package binary"); + writeln!(binary, "echo test").expect("failed to write package binary"); + + fs::write(lib_dir.join("libswiftCore.dylib"), "swift").expect("failed to write dylib"); + + let tarball_path = temp.path().join("oz.tar.gz"); + let tar_output = Command::new("tar") + .arg("-czf") + .arg(&tarball_path) + .arg("-C") + .arg(&package_dir) + .arg(".") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .expect("failed to run tar"); + assert!( + tar_output.status.success(), + "tar failed with {:?}: stderr={}", + tar_output.status, + String::from_utf8_lossy(&tar_output.stderr), + ); + + fs::create_dir_all(&fake_home).expect("failed to create fake home"); + let script = install_script(Some( + tarball_path + .to_str() + .expect("temp tarball path should be valid UTF-8"), + )); + + let bash = if Path::new("/bin/bash").exists() { + "/bin/bash" + } else { + "bash" + }; + let output = Command::new(bash) + .arg("-c") + .arg(&script) + .env("HOME", &fake_home) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .expect("failed to run install script"); + assert!( + output.status.success(), + "install script failed with {:?}: stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr), + ); + + let installed_binary = remote_server_binary().replacen( + '~', + fake_home + .to_str() + .expect("fake home path should be valid UTF-8"), + 1, + ); + assert!( + Path::new(&installed_binary).is_file(), + "expected binary at unchanged remote-server path: {installed_binary}", + ); + + let installed_lib = remote_server_dir().replacen( + '~', + fake_home + .to_str() + .expect("fake home path should be valid UTF-8"), + 1, + ) + "/lib/libswiftCore.dylib"; + assert!( + Path::new(&installed_lib).is_file(), + "expected Swift sidecar dylib at {installed_lib}", + ); +} #[test] fn version_hash_is_deterministic() { // version_hash uses the compile-time GIT_RELEASE_TAG which is typically diff --git a/script/macos/bundle b/script/macos/bundle index 4e87c7daa8..a7a08c2f69 100755 --- a/script/macos/bundle +++ b/script/macos/bundle @@ -92,6 +92,53 @@ function relpath() { python -c "import os,sys;print(os.path.relpath(*(sys.argv[1:])))" "$@"; } +function add_rpath_if_missing() { + local binary_path="$1" + local rpath="$2" + + if otool -l "$binary_path" | awk -v rpath="$rpath" ' + /LC_RPATH/ { in_rpath = 1; next } + in_rpath && $1 == "path" { + if ($2 == rpath) { found = 1 } + in_rpath = 0 + } + END { exit found ? 0 : 1 } + '; then + echo "$binary_path already has rpath $rpath" + else + echo "Adding rpath $rpath to $binary_path" + install_name_tool -add_rpath "$rpath" "$binary_path" + fi +} + +function copy_swift_stdlibs_for_cli() { + local binary_path="$1" + local destination="$2" + local swift_stdlib_tool_output + swift_stdlib_tool_output="$(mktemp -d -t swift-stdlib-tool-XXXXXXXXXX)" + + rm -rf "$destination" + + mkdir -p "$destination" + echo "Copying Swift runtime libraries required by $binary_path into $destination" + xcrun swift-stdlib-tool \ + --copy \ + --scan-executable "$binary_path" \ + --platform macosx \ + --destination "$swift_stdlib_tool_output" + + find "$swift_stdlib_tool_output" -type f -name 'libswift*.dylib' -maxdepth 3 -print0 | while IFS= read -r -d '' dylib_path; do + cp -p "$dylib_path" "$destination/" + done + # Do not rewrite /usr/lib/swift load commands to @rpath. Xcode's x86_64 + # backward-deployment Swift 5.0 dylibs are only valid before macOS 10.14.4 + # and abort on newer macOS when loaded directly. Keeping the system Swift + # load commands preserves modern runtime behavior while still packaging the + # stdlibs swift-stdlib-tool reports for the archive layout. + + rm -rf "$swift_stdlib_tool_output" +} + # Defaults for command-line flags. UNIVERSAL_BINARY=true BUILD_BINARY=true @@ -587,6 +634,10 @@ elif [[ "$ARTIFACT" == "cli" ]]; then echo "Copying binary into $OUT_DIR/$WARP_BIN" cp "target/$DEFAULT_TARGET/$TARGET_PROFILE_DIR/$WARP_BIN" "$OUT_DIR/$WARP_BIN" + # Prefer system Swift libraries on macOS versions that provide them, and fall + # back to the sidecar lib directory on older systems. + add_rpath_if_missing "$OUT_DIR/$WARP_BIN" "/usr/lib/swift" + add_rpath_if_missing "$OUT_DIR/$WARP_BIN" "@executable_path/lib" if [[ -n "$TARGET_ARCH" && -e "target/$DEFAULT_TARGET/$TARGET_PROFILE_DIR/$WARP_BIN.dSYM" ]]; then echo "Copying .dSYM into $OUT_DIR/$WARP_BIN.dSYM" @@ -596,6 +647,8 @@ elif [[ "$ARTIFACT" == "cli" ]]; then echo "Preparing CLI resources directory" BUNDLED_RESOURCES_DIR="$OUT_DIR/resources" "$WORKSPACE_ROOT_DIR/script/prepare_bundled_resources" "$BUNDLED_RESOURCES_DIR" "$RELEASE_CHANNEL" "$CARGO_PROFILE" + CLI_SWIFT_STDLIB_DIR="$OUT_DIR/lib" + copy_swift_stdlibs_for_cli "$OUT_DIR/$WARP_BIN" "$CLI_SWIFT_STDLIB_DIR" # Set the primary binary path to output. @@ -840,6 +893,10 @@ if [ "${GITHUB_ACTIONS}" == "true" ]; then if [[ -n "$BUNDLED_RESOURCES_DIR" ]]; then echo "bundled_resources_dir=$BUNDLED_RESOURCES_DIR" >> "$GITHUB_OUTPUT" fi + + if [[ -n "$CLI_SWIFT_STDLIB_DIR" ]]; then + echo "cli_swift_stdlib_dir=$CLI_SWIFT_STDLIB_DIR" >> "$GITHUB_OUTPUT" + fi echo "::echo::off" fi