diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 4923b72..22c2d0f 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -43,7 +43,7 @@ jobs: run: cargo +${RUST_TOOLCHAIN} run --locked --package bds-release -- prepare build-macos: - needs: prepare-release + needs: [prepare-release, build-linux-x64] runs-on: linux-arm64 container: ghcr.io/rust-cross/cargo-zigbuild@sha256:82af75c41958c2af2787e8bedd912da7678a9438937e223e9d83d006d747b38b env: @@ -96,7 +96,7 @@ jobs: done build-linux-arm64: - needs: prepare-release + needs: [prepare-release, build-linux-x64] runs-on: linux-arm64 env: CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS: -C link-arg=-Wl,-rpath,$ORIGIN @@ -174,11 +174,11 @@ jobs: "$GITEA_REF_NAME" "target/$target/release" "$rust_lib_dir" linux-x64 dist --upload build-windows: - needs: prepare-release + needs: [prepare-release, build-linux-x64] runs-on: linux-arm64 container: messense/cargo-xwin@sha256:4696dd4e79edf8569fa99c4b06bd99273e0501c7adc983aa61d57945f795bef0 env: - CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS: -C target-feature=+crt-static + CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS: -Lnative=target/x86_64-pc-windows-msvc/release steps: - name: Check out the tag env: @@ -211,6 +211,10 @@ jobs: run: | set -eu target=x86_64-pc-windows-msvc + cargo +${RUST_TOOLCHAIN} xwin cache xwin + cargo +${RUST_TOOLCHAIN} run --locked --package bds-release -- \ + windows-runtime "target/$target/release" \ + /root/.cache/cargo-xwin/xwin/sdk/lib/um/x86_64 cargo +${RUST_TOOLCHAIN} xwin build --release --locked --target "$target" \ --package bds-ui --package bds-cli --package bds-mcp rust_lib_dir=$(rustc +${RUST_TOOLCHAIN} --target "$target" --print target-libdir) diff --git a/BUILD.md b/BUILD.md index 3a28da1..4a3e04e 100644 --- a/BUILD.md +++ b/BUILD.md @@ -24,7 +24,7 @@ Windows packaging requires the MSVC Rust toolchain, Visual Studio Build Tools wi Pushing a tag runs `.gitea/workflows/release.yml` on Linux arm64. The workflow builds Linux arm64 natively, cross-links Linux x64 with Ubuntu's amd64 development libraries and GNU cross-toolchain, uses Cargo Zigbuild for macOS, Cargo Xwin for Windows x64, and `bds-release` for every packaging and Gitea API operation. It does not require host installations of Python, `curl`, `tar`, `hdiutil`, or NSIS. -The workflow publishes CLI/MCP and desktop artifacts for Linux and macOS on arm64 and x64, plus Windows x64. Linux desktop binaries dynamically use the target system's GTK 3, WebKitGTK 4.1, and libxdo runtime packages. macOS desktop artifacts are ad-hoc-signed `.dmg` files, Unix artifacts are `.tar.gz`, and Windows artifacts are `.zip` files containing the native `.exe` application and its runtime DLLs. The Gitea release remains a draft unless every build and upload succeeds. +The workflow publishes CLI/MCP and desktop artifacts for Linux and macOS on arm64 and x64, plus Windows x64. Linux desktop binaries dynamically use the target system's GTK 3, WebKitGTK 4.1, and libxdo runtime packages. Every archive includes its downloaded ONNX Runtime shared library; Windows archives also include the checksum-pinned Microsoft DirectML redistributable and license. macOS desktop artifacts are ad-hoc-signed `.dmg` files, Unix artifacts are `.tar.gz`, and Windows artifacts are `.zip` files containing the native `.exe` application and its runtime DLLs. The Gitea release remains a draft unless every build and upload succeeds. ## macOS system requirements diff --git a/Cargo.lock b/Cargo.lock index 1256d05..5b010b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1213,6 +1213,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha2 0.10.9", "simple-file-manifest", "tar", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 6acaa79..4a145dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,7 +62,7 @@ mlua = { version = "0.12", features = ["lua54", "vendored", "serialize"] } url = "2.5" fastembed = { version = "5.17.3", default-features = false, features = ["hf-hub-rustls-tls", "ort-download-binaries-rustls-tls"] } usearch = { version = "2.26", default-features = false } -ort = { version = "=2.0.0-rc.12", default-features = false, features = ["std", "api-24", "coreml", "directml"] } +ort = { version = "=2.0.0-rc.12", default-features = false, features = ["std", "api-24", "copy-dylibs"] } russh = "0.62.5" ratatui = "0.30.2" ratatui-image = { version = "11.0.6", default-features = false, features = ["crossterm", "image-defaults"] } diff --git a/README.md b/README.md index 78cde48..6eddf76 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ RuDS uses no JavaScript application runtime and loads no CSS or JavaScript from The packaged Apple Silicon application requires macOS 26 or newer. -Packaged executables share native `bds-core` and `bds-server` dynamic libraries. ONNX Runtime is statically contained in `bds-core`; the package does not ship or download a separate ONNX library. The **Install CLI** action replaces `~/.local/bin/bds-cli` with a symbolic link to the packaged CLI, so the command executes beside the same runtime libraries and always follows the package selected by the latest installation. +Packaged executables share native `bds-core` and `bds-server` dynamic libraries. Release archives also carry the matching ONNX Runtime library; Windows archives include Microsoft's DirectML redistributable and its license for GPU-accelerated local embeddings. The **Install CLI** action replaces `~/.local/bin/bds-cli` with a symbolic link to the packaged CLI, so the command executes beside the same runtime libraries and always follows the package selected by the latest installation. Local macOS packages are ad-hoc signed without hardened runtime. A Developer ID and notarization remain optional release-channel steps for downloads that should pass Gatekeeper without a user override. diff --git a/crates/bds-core/Cargo.toml b/crates/bds-core/Cargo.toml index 6092d25..9b9c75f 100644 --- a/crates/bds-core/Cargo.toml +++ b/crates/bds-core/Cargo.toml @@ -48,12 +48,14 @@ fastembed = { workspace = true } ort = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] +ort = { workspace = true, features = ["coreml"] } usearch = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] # libwebp's MSVC autodetection enables SSE 4.1 sources, so clang-cl must # compile them with the matching target feature during cargo-xwin builds. libwebp-sys = { workspace = true, features = ["sse41"] } +ort = { workspace = true, features = ["directml"] } [target.'cfg(not(target_os = "macos"))'.dependencies] usearch = { workspace = true, features = ["numkong"] } diff --git a/crates/bds-release/Cargo.toml b/crates/bds-release/Cargo.toml index b1dc187..4358b20 100644 --- a/crates/bds-release/Cargo.toml +++ b/crates/bds-release/Cargo.toml @@ -16,6 +16,7 @@ plist.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true +sha2.workspace = true simple-file-manifest.workspace = true tar.workspace = true zip.workspace = true diff --git a/crates/bds-release/src/main.rs b/crates/bds-release/src/main.rs index 5aa4166..cfa4f40 100644 --- a/crates/bds-release/src/main.rs +++ b/crates/bds-release/src/main.rs @@ -11,6 +11,9 @@ fn main() -> Result<(), Box> { [command, directory] if command == "macos-linker-alias" => { packaging::prepare_macos_linker_alias(Path::new(directory)) } + [command, directory, sdk_libraries] if command == "windows-runtime" => { + packaging::prepare_windows_runtime(Path::new(directory), Path::new(sdk_libraries)) + } [command, paths @ ..] if command == "upload" && !paths.is_empty() => { gitea::upload(paths.iter().map(Path::new)) } @@ -39,7 +42,7 @@ fn main() -> Result<(), Box> { gitea::upload(assets.iter().map(|path| path.as_path())) } _ => Err( - "usage: bds-release prepare|publish|macos-linker-alias |upload ...|package [--upload]" + "usage: bds-release prepare|publish|macos-linker-alias |windows-runtime |upload ...|package [--upload]" .into(), ), } diff --git a/crates/bds-release/src/packaging.rs b/crates/bds-release/src/packaging.rs index 365a267..e34d592 100644 --- a/crates/bds-release/src/packaging.rs +++ b/crates/bds-release/src/packaging.rs @@ -3,12 +3,13 @@ use apple_codesign::{SigningSettings, UnifiedSigner}; use flate2::{Compression, write::GzEncoder}; use icns::{IconFamily, Image}; use plist::{Dictionary, Value}; +use sha2::{Digest, Sha256}; use simple_file_manifest::FileEntry; use std::{ error::Error, ffi::OsStr, fs::{self, File}, - io::{self, BufReader}, + io::{self, BufReader, Read, Seek}, path::{Path, PathBuf}, }; use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions}; @@ -23,6 +24,8 @@ const LC_LOAD_WEAK_DYLIB: u32 = 0x8000_0018; const LC_REEXPORT_DYLIB: u32 = 0x8000_001f; const LC_LAZY_LOAD_DYLIB: u32 = 0x20; const LC_LOAD_UPWARD_DYLIB: u32 = 0x8000_0023; +const DIRECTML_VERSION: &str = "1.15.4"; +const DIRECTML_SHA256: &str = "4e7cb7ddce8cf837a7a75dc029209b520ca0101470fcdf275c1f49736a3615b9"; pub(crate) fn prepare_macos_linker_alias(directory: &Path) -> Result<(), Box> { fs::create_dir_all(directory)?; @@ -30,6 +33,84 @@ pub(crate) fn prepare_macos_linker_alias(directory: &Path) -> Result<(), Box Result<(), Box> { + fs::create_dir_all(directory)?; + let package_path = directory.join(format!("microsoft.ai.directml.{DIRECTML_VERSION}.nupkg")); + let result = (|| { + let url = format!( + "https://api.nuget.org/v3-flatcontainer/microsoft.ai.directml/{DIRECTML_VERSION}/microsoft.ai.directml.{DIRECTML_VERSION}.nupkg" + ); + let mut response = reqwest::blocking::get(url)?.error_for_status()?; + let mut package = File::create(&package_path)?; + io::copy(&mut response, &mut package)?; + drop(package); + + let mut package = BufReader::new(File::open(&package_path)?); + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = package.read(&mut buffer)?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + let digest = digest.finalize(); + if format!("{digest:x}") != DIRECTML_SHA256 { + return Err("DirectML package checksum mismatch".into()); + } + prepare_windows_runtime_from_archive(File::open(&package_path)?, directory)?; + prepare_windows_sdk_linker_aliases(sdk_libraries, directory) + })(); + if package_path.try_exists()? { + fs::remove_file(package_path)?; + } + result +} + +fn prepare_windows_sdk_linker_aliases( + sdk_libraries: &Path, + directory: &Path, +) -> Result<(), Box> { + let source = fs::read_dir(sdk_libraries)? + .filter_map(Result::ok) + .find(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| name.eq_ignore_ascii_case("pathcch.lib")) + }) + .ok_or_else(|| { + format!( + "PathCch.lib is missing from Windows SDK directory: {}", + sdk_libraries.display() + ) + })? + .path(); + fs::copy(source, directory.join("PathCch.lib"))?; + Ok(()) +} + +fn prepare_windows_runtime_from_archive( + archive: impl Read + Seek, + directory: &Path, +) -> Result<(), Box> { + fs::create_dir_all(directory)?; + let mut archive = zip::ZipArchive::new(archive)?; + for (source, destination) in [ + ("bin/x64-win/DirectML.lib", "DirectML.lib"), + ("bin/x64-win/DirectML.dll", "DirectML.dll"), + ("LICENSE.txt", "DirectML-LICENSE.txt"), + ] { + let mut source = archive.by_name(source)?; + io::copy(&mut source, &mut File::create(directory.join(destination))?)?; + } + Ok(()) +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum Platform { MacOs, @@ -177,6 +258,37 @@ fn runtime_files( .to_owned(); files.push((path, name)); } + let mut external_names = match platform { + Platform::MacOs => fs::read_dir(release_dir)? + .filter_map(Result::ok) + .map(|entry| entry.file_name()) + .filter_map(|name| name.into_string().ok()) + .filter(|name| name.starts_with("libonnxruntime") && name.contains(".dylib")) + .collect::>(), + Platform::Linux => fs::read_dir(release_dir)? + .filter_map(Result::ok) + .map(|entry| entry.file_name()) + .filter_map(|name| name.into_string().ok()) + .filter(|name| name.starts_with("libonnxruntime.so")) + .collect::>(), + Platform::Windows => ["onnxruntime.dll", "DirectML.dll", "DirectML-LICENSE.txt"] + .into_iter() + .map(str::to_owned) + .collect(), + }; + external_names.sort(); + if external_names.is_empty() { + return Err(format!( + "ONNX Runtime is missing from release directory: {}", + release_dir.display() + ) + .into()); + } + for name in external_names { + let path = release_dir.join(&name); + require_file(&path)?; + files.push((path, name)); + } Ok(files) } @@ -443,7 +555,7 @@ fn dmg_sectors(bundle_bytes: u64) -> Result> { #[cfg(test)] mod tests { use super::*; - use std::io::Read; + use std::io::{Read, Write}; use tempfile::tempdir; #[test] @@ -477,6 +589,50 @@ mod tests { ); } + #[test] + fn windows_runtime_extracts_only_redistributable_files() { + let temp = tempdir().unwrap(); + let sdk = temp.path().join("sdk"); + let runtime = temp.path().join("runtime"); + fs::create_dir(&sdk).unwrap(); + fs::create_dir(&runtime).unwrap(); + fs::write(sdk.join("pathcch.lib"), b"sdk-import-library").unwrap(); + let archive = std::io::Cursor::new(Vec::new()); + let mut archive = ZipWriter::new(archive); + let options = SimpleFileOptions::default(); + for (name, contents) in [ + ("bin/x64-win/DirectML.lib", b"import-library".as_slice()), + ("bin/x64-win/DirectML.dll", b"runtime".as_slice()), + ("LICENSE.txt", b"license".as_slice()), + ( + "bin/arm64-win/DirectML.dll", + b"wrong-architecture".as_slice(), + ), + ] { + archive.start_file(name, options).unwrap(); + archive.write_all(contents).unwrap(); + } + let archive = archive.finish().unwrap(); + + prepare_windows_runtime_from_archive(archive, &runtime).unwrap(); + prepare_windows_sdk_linker_aliases(&sdk, &runtime).unwrap(); + + assert_eq!( + fs::read(runtime.join("DirectML.lib")).unwrap(), + b"import-library" + ); + assert_eq!(fs::read(runtime.join("DirectML.dll")).unwrap(), b"runtime"); + assert_eq!( + fs::read(runtime.join("DirectML-LICENSE.txt")).unwrap(), + b"license" + ); + assert_eq!( + fs::read(runtime.join("PathCch.lib")).unwrap(), + b"sdk-import-library" + ); + assert_eq!(fs::read_dir(runtime).unwrap().count(), 4); + } + #[test] fn windows_package_contains_the_app_without_dynamic_std() { let temp = tempdir().unwrap(); @@ -491,6 +647,9 @@ mod tests { "bds-mcp.exe", "bds_core.dll", "bds_server.dll", + "onnxruntime.dll", + "DirectML.dll", + "DirectML-LICENSE.txt", ] { fs::write(release.join(file), format!("MZ-{file}")).unwrap(); } @@ -511,6 +670,14 @@ mod tests { .by_name("bds-desktop-v1.2.3-windows-x64/bds_core.dll") .is_ok() ); + for runtime in ["onnxruntime.dll", "DirectML.dll", "DirectML-LICENSE.txt"] { + assert!( + archive + .by_name(&format!("bds-desktop-v1.2.3-windows-x64/{runtime}")) + .is_ok(), + "missing {runtime}" + ); + } } #[test] @@ -527,6 +694,7 @@ mod tests { "bds-mcp", "libbds_core.so", "libbds_server.so", + "libonnxruntime.so.1", ] { fs::write(release.join(file), file).unwrap(); } @@ -549,6 +717,11 @@ mod tests { .unwrap() .map(|entry| entry.unwrap().path().unwrap().into_owned()) .collect::>(); + assert!( + names + .iter() + .any(|name| name.ends_with("libonnxruntime.so.1")) + ); assert!(names.iter().any(|name| name.ends_with("bds-ui"))); assert!(names.iter().any(|name| name.ends_with("libbds_core.so"))); } diff --git a/crates/bds-ui/tests/packaging_assets.rs b/crates/bds-ui/tests/packaging_assets.rs index ac2b798..ff1bdf2 100644 --- a/crates/bds-ui/tests/packaging_assets.rs +++ b/crates/bds-ui/tests/packaging_assets.rs @@ -60,6 +60,7 @@ fn desktop_packages_have_native_icons_and_cargo_commands() { assert!(workspace_manifest.contains("Georg Bauer ")); assert!(workspace_manifest.contains("strip = \"symbols\"")); assert!(workspace_manifest.contains("ort-download-binaries-rustls-tls")); + assert!(workspace_manifest.contains("\"copy-dylibs\"")); assert!(workspace_manifest.contains("\"sync-secret-service\", \"vendored\"")); assert!( workspace_manifest @@ -134,6 +135,10 @@ fn tagged_releases_are_built_and_packaged_with_rust_tools() { "aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu", "cargo-xwin", + "windows-runtime", + "cargo +${RUST_TOOLCHAIN} xwin cache xwin", + "/root/.cache/cargo-xwin/xwin/sdk/lib/um/x86_64", + "-Lnative=target/x86_64-pc-windows-msvc/release", "apt-get install --no-install-recommends --yes nasm", "x86_64-pc-windows-msvc", "bds-release", @@ -155,6 +160,10 @@ fn tagged_releases_are_built_and_packaged_with_rust_tools() { "release workflow must not use host tool {forbidden}" ); } + assert!( + !workflow.contains("target-feature=+crt-static"), + "Windows and downloaded ONNX Runtime must use the same dynamic CRT" + ); assert!( !workflow.contains("run --release --locked --package bds-release"), "the release helper itself must stay unoptimized so cold runner jobs remain practical" @@ -166,6 +175,13 @@ fn tagged_releases_are_built_and_packaged_with_rust_tools() { 4, "every release job using the Ubuntu runner image must bootstrap Rust" ); + assert_eq!( + workflow + .matches("needs: [prepare-release, build-linux-x64]") + .count(), + 3, + "the expensive platform builds must wait for the Linux x64 cross-build smoke test" + ); let linux_dependencies = fs::read_to_string(workspace.join(".gitea/scripts/install-linux-build-dependencies.sh"))