Add multi-platform release builds for 0.9.0 (closes #138).
Some checks failed
Tagged release / prepare-release (push) Failing after 5s
Tagged release / build-macos (push) Has been skipped
Tagged release / build-linux-arm64 (push) Has been skipped
Tagged release / build-linux-x64 (push) Has been skipped
Tagged release / build-windows (push) Has been skipped
Tagged release / publish-release (push) Has been skipped

This commit is contained in:
Hermes Agent
2026-08-14 09:03:42 +00:00
parent 20f9d15b46
commit 917947a421
14 changed files with 3134 additions and 127 deletions

View File

@@ -0,0 +1,561 @@
use apple_bundles::MacOsApplicationBundleBuilder;
use apple_codesign::{SigningSettings, UnifiedSigner};
use flate2::{Compression, write::GzEncoder};
use icns::{IconFamily, Image};
use plist::{Dictionary, Value};
use simple_file_manifest::FileEntry;
use std::{
error::Error,
ffi::OsStr,
fs::{self, File},
io::{self, BufReader},
path::{Path, PathBuf},
};
use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
const APP_NAME: &str = "Blogging Desktop Server";
const APP_EXECUTABLE: &str = "bds-ui";
const BUNDLE_ID: &str = "de.rfc1437.ruds";
const MIB: u64 = 1024 * 1024;
const LC_LOAD_DYLIB: u32 = 0x0c;
const LC_ID_DYLIB: u32 = 0x0d;
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;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Platform {
MacOs,
Linux,
Windows,
}
impl Platform {
fn from_label(label: &str) -> Result<Self, Box<dyn Error>> {
if label.starts_with("darwin-") {
Ok(Self::MacOs)
} else if label.starts_with("linux-") {
Ok(Self::Linux)
} else if label.starts_with("windows-") {
Ok(Self::Windows)
} else {
Err(format!("unsupported release platform: {label}").into())
}
}
fn executable(self, name: &str) -> String {
if self == Self::Windows {
format!("{name}.exe")
} else {
name.to_owned()
}
}
fn shared_libraries(self) -> [&'static str; 2] {
match self {
Self::MacOs => ["libbds_core.dylib", "libbds_server.dylib"],
Self::Linux => ["libbds_core.so", "libbds_server.so"],
Self::Windows => ["bds_core.dll", "bds_server.dll"],
}
}
fn is_dynamic_std(self, path: &Path) -> bool {
let name = path.file_name().and_then(OsStr::to_str).unwrap_or_default();
match self {
Self::MacOs => name.starts_with("libstd-") && name.ends_with(".dylib"),
Self::Linux => name.starts_with("libstd-") && name.ends_with(".so"),
Self::Windows => name.starts_with("std-") && name.ends_with(".dll"),
}
}
}
pub(crate) fn package(
release_tag: &str,
release_dir: &Path,
rust_lib_dir: &Path,
platform_label: &str,
dist_dir: &Path,
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
let platform = Platform::from_label(platform_label)?;
fs::create_dir_all(dist_dir)?;
let version = safe_tag(release_tag);
let runtime = runtime_files(platform, release_dir, rust_lib_dir)?;
let cli_files = executable_files(platform, release_dir, &["bds-cli", "bds-mcp"])?;
let cli_name = format!("bds-cli-{version}-{platform_label}");
let desktop_name = format!("bds-desktop-{version}-{platform_label}");
let cli_asset = match platform {
Platform::Windows => {
let path = dist_dir.join(format!("{cli_name}.zip"));
zip_archive(&path, &cli_name, cli_files.iter().chain(&runtime))?;
path
}
Platform::MacOs | Platform::Linux => {
let path = dist_dir.join(format!("{cli_name}.tar.gz"));
tar_gz_archive(&path, &cli_name, cli_files.iter().chain(&runtime))?;
path
}
};
let desktop_files = executable_files(
platform,
release_dir,
&[APP_EXECUTABLE, "bds-cli", "bds-mcp"],
)?;
let desktop_asset = match platform {
Platform::MacOs => {
let path = dist_dir.join(format!("{desktop_name}.dmg"));
macos_dmg(release_tag, &path, desktop_files.iter().chain(&runtime))?;
path
}
Platform::Linux => {
let path = dist_dir.join(format!("{desktop_name}.tar.gz"));
tar_gz_archive(&path, &desktop_name, desktop_files.iter().chain(&runtime))?;
path
}
Platform::Windows => {
let path = dist_dir.join(format!("{desktop_name}.zip"));
zip_archive(&path, &desktop_name, desktop_files.iter().chain(&runtime))?;
path
}
};
println!("created {}", cli_asset.display());
println!("created {}", desktop_asset.display());
Ok(vec![cli_asset, desktop_asset])
}
fn executable_files(
platform: Platform,
release_dir: &Path,
names: &[&str],
) -> Result<Vec<(PathBuf, String)>, Box<dyn Error>> {
names
.iter()
.map(|name| {
let file_name = platform.executable(name);
let path = release_dir.join(&file_name);
require_file(&path)?;
Ok((path, file_name))
})
.collect()
}
fn runtime_files(
platform: Platform,
release_dir: &Path,
rust_lib_dir: &Path,
) -> Result<Vec<(PathBuf, String)>, Box<dyn Error>> {
let mut files = platform
.shared_libraries()
.into_iter()
.map(|name| {
let path = release_dir.join(name);
require_file(&path)?;
Ok((path, name.to_owned()))
})
.collect::<Result<Vec<_>, Box<dyn Error>>>()?;
let mut std_libraries = fs::read_dir(rust_lib_dir)?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| platform.is_dynamic_std(path))
.collect::<Vec<_>>();
std_libraries.sort();
for path in std_libraries {
let name = path
.file_name()
.and_then(OsStr::to_str)
.ok_or_else(|| format!("invalid runtime filename: {}", path.display()))?
.to_owned();
files.push((path, name));
}
Ok(files)
}
fn require_file(path: &Path) -> Result<(), Box<dyn Error>> {
if path.is_file() {
Ok(())
} else {
Err(format!("release file is missing: {}", path.display()).into())
}
}
fn ensure_new(path: &Path) -> Result<(), Box<dyn Error>> {
if path.try_exists()? {
Err(format!("output already exists: {}", path.display()).into())
} else {
Ok(())
}
}
fn tar_gz_archive<'a>(
output: &Path,
root: &str,
files: impl Iterator<Item = &'a (PathBuf, String)>,
) -> Result<(), Box<dyn Error>> {
ensure_new(output)?;
let encoder = GzEncoder::new(File::create(output)?, Compression::best());
let mut archive = tar::Builder::new(encoder);
for (source, name) in files {
archive.append_path_with_name(source, Path::new(root).join(name))?;
}
archive.into_inner()?.finish()?;
Ok(())
}
fn zip_archive<'a>(
output: &Path,
root: &str,
files: impl Iterator<Item = &'a (PathBuf, String)>,
) -> Result<(), Box<dyn Error>> {
ensure_new(output)?;
let mut archive = ZipWriter::new(File::create(output)?);
let options = SimpleFileOptions::default()
.compression_method(CompressionMethod::Deflated)
.unix_permissions(0o755);
for (source, name) in files {
archive.start_file(format!("{root}/{name}"), options)?;
io::copy(&mut File::open(source)?, &mut archive)?;
}
archive.finish()?;
Ok(())
}
fn macos_dmg<'a>(
release_tag: &str,
output: &Path,
files: impl Iterator<Item = &'a (PathBuf, String)>,
) -> Result<(), Box<dyn Error>> {
let files = files.collect::<Vec<_>>();
ensure_new(output)?;
let version = bundle_version(release_tag)?;
let output_dir = output.parent().unwrap_or_else(|| Path::new("."));
let bundle_path = output_dir.join(format!("{APP_NAME}.app"));
ensure_new(&bundle_path)?;
let mut bundle = MacOsApplicationBundleBuilder::new(APP_NAME)?;
bundle.set_info_plist_required_keys(APP_NAME, BUNDLE_ID, version, "RuDS", APP_EXECUTABLE)?;
bundle.set_info_plist_key("CFBundleShortVersionString", version)?;
bundle.set_info_plist_key("CFBundleIconFile", format!("{APP_NAME}.icns"))?;
bundle.set_info_plist_key("CFBundlePackageType", "APPL")?;
bundle.set_info_plist_key(
"LSApplicationCategoryType",
"public.app-category.productivity",
)?;
bundle.set_info_plist_key("LSMinimumSystemVersion", "26.0")?;
bundle.set_info_plist_key("NSHighResolutionCapable", true)?;
bundle.set_info_plist_key("CFBundleURLTypes", url_types())?;
bundle.add_icon(icns()?)?;
for (source, name) in &files {
let entry = FileEntry::new_from_path(source, true);
if name.ends_with(".dylib") {
bundle.add_file_resources(name, entry)?;
} else {
bundle.add_file_macos(name, entry)?;
}
}
let materialized = bundle.materialize_bundle(output_dir)?;
let runtime_names = files
.iter()
.filter(|(_, name)| name.ends_with(".dylib"))
.map(|(_, name)| name.clone())
.collect::<Vec<_>>();
for (_, name) in &files {
let staged = if name.ends_with(".dylib") {
materialized.join("Contents/Resources").join(name)
} else {
materialized.join("Contents/MacOS").join(name)
};
rewrite_macho_rpaths(&staged, &runtime_names)?;
}
let signer = UnifiedSigner::new(SigningSettings::default());
signer.sign_path_in_place(&materialized)?;
apple_dmg::create_dmg(
&materialized,
output,
APP_NAME,
dmg_sectors(directory_size(&materialized)?)?,
)?;
signer.sign_path_in_place(output)?;
fs::remove_dir_all(materialized)?;
Ok(())
}
fn rewrite_macho_rpaths(path: &Path, runtime_names: &[String]) -> Result<(), Box<dyn Error>> {
let mut bytes = fs::read(path)?;
if read_u32(&bytes, 0)? != 0xfeed_facf {
return Err(format!(
"release file is not a 64-bit little-endian Mach-O: {}",
path.display()
)
.into());
}
let command_count = read_u32(&bytes, 16)? as usize;
let commands_size = read_u32(&bytes, 20)? as usize;
let commands_end = 32_usize
.checked_add(commands_size)
.filter(|end| *end <= bytes.len())
.ok_or_else(|| format!("invalid Mach-O load commands in {}", path.display()))?;
let mut command_start = 32_usize;
for _ in 0..command_count {
let command = read_u32(&bytes, command_start)?;
let command_size = read_u32(&bytes, command_start + 4)? as usize;
let command_end = command_start
.checked_add(command_size)
.filter(|end| command_size >= 8 && *end <= commands_end)
.ok_or_else(|| format!("invalid Mach-O load command in {}", path.display()))?;
if is_dylib_command(command) {
if command_size < 24 {
return Err(format!("invalid Mach-O dylib command in {}", path.display()).into());
}
let name_offset = read_u32(&bytes, command_start + 8)? as usize;
let name_start = command_start
.checked_add(name_offset)
.filter(|start| name_offset >= 24 && *start < command_end)
.ok_or_else(|| format!("invalid Mach-O dylib name in {}", path.display()))?;
let name_length = bytes[name_start..command_end]
.iter()
.position(|byte| *byte == 0)
.ok_or_else(|| format!("unterminated Mach-O dylib name in {}", path.display()))?;
let current = std::str::from_utf8(&bytes[name_start..name_start + name_length])?;
let file_name = current.rsplit('/').next().unwrap_or(current);
if runtime_names.iter().any(|runtime| runtime == file_name) {
let replacement = format!("@rpath/{file_name}");
let capacity = command_end - name_start;
if replacement.len() + 1 > capacity {
return Err(format!(
"Mach-O load command has no room for {replacement} in {}",
path.display()
)
.into());
}
bytes[name_start..command_end].fill(0);
bytes[name_start..name_start + replacement.len()]
.copy_from_slice(replacement.as_bytes());
}
}
command_start = command_end;
}
if command_start != commands_end {
return Err(format!("inconsistent Mach-O load commands in {}", path.display()).into());
}
fs::write(path, bytes)?;
Ok(())
}
fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, Box<dyn Error>> {
let value = bytes
.get(offset..offset + 4)
.ok_or("truncated Mach-O data")?;
Ok(u32::from_le_bytes(value.try_into()?))
}
fn is_dylib_command(command: u32) -> bool {
matches!(
command,
LC_LOAD_DYLIB
| LC_ID_DYLIB
| LC_LOAD_WEAK_DYLIB
| LC_REEXPORT_DYLIB
| LC_LAZY_LOAD_DYLIB
| LC_LOAD_UPWARD_DYLIB
)
}
fn url_types() -> Value {
let mut url_type = Dictionary::new();
url_type.insert("CFBundleURLName".to_owned(), BUNDLE_ID.into());
url_type.insert(
"CFBundleURLSchemes".to_owned(),
Value::Array(vec!["ruds".into()]),
);
Value::Array(vec![Value::Dictionary(url_type)])
}
fn icns() -> Result<Vec<u8>, Box<dyn Error>> {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../bds-ui/assets/app-icons/bds.png");
let image = Image::read_png(BufReader::new(File::open(path)?))?;
let mut family = IconFamily::new();
family.add_icon(&image)?;
let mut bytes = Vec::new();
family.write(&mut bytes)?;
Ok(bytes)
}
fn bundle_version(tag: &str) -> Result<&str, Box<dyn Error>> {
let version = tag.strip_prefix('v').unwrap_or(tag);
let version = version.split(['-', '+']).next().unwrap_or_default();
let parts = version.split('.').collect::<Vec<_>>();
if !(1..=3).contains(&parts.len())
|| parts
.iter()
.any(|part| part.is_empty() || !part.bytes().all(|byte| byte.is_ascii_digit()))
{
return Err(format!("release tag is not a numeric Apple bundle version: {tag}").into());
}
Ok(version)
}
fn safe_tag(tag: &str) -> String {
tag.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') {
character
} else {
'-'
}
})
.collect()
}
fn directory_size(path: &Path) -> io::Result<u64> {
fs::read_dir(path)?.try_fold(0_u64, |total, entry| {
let entry = entry?;
let metadata = entry.metadata()?;
let size = if metadata.is_dir() {
directory_size(&entry.path())?
} else {
metadata.len()
};
total
.checked_add(size)
.ok_or_else(|| io::Error::other("bundle size overflow"))
})
}
fn dmg_sectors(bundle_bytes: u64) -> Result<u32, Box<dyn Error>> {
let capacity = bundle_bytes
.checked_add((bundle_bytes / 10).max(16 * MIB))
.ok_or("DMG capacity overflow")?
.max(64 * MIB);
Ok(u32::try_from(capacity.div_ceil(512))?)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
use tempfile::tempdir;
#[test]
fn apple_bundle_version_comes_from_semver_tag() {
assert_eq!(bundle_version("v1.2.3").unwrap(), "1.2.3");
assert_eq!(bundle_version("2.0.0-rc.1").unwrap(), "2.0.0");
assert!(bundle_version("nightly").is_err());
assert!(bundle_version("1.2.3.4").is_err());
}
#[test]
fn asset_tag_is_safe_for_paths_and_urls() {
assert_eq!(safe_tag("release/1 + beta"), "release-1---beta");
}
#[test]
fn dmg_capacity_has_filesystem_headroom() {
let sectors = dmg_sectors(100 * MIB).unwrap();
assert!(u64::from(sectors) * 512 >= 116 * MIB);
assert_eq!(dmg_sectors(1).unwrap(), 131_072);
}
#[test]
fn windows_package_contains_the_app_without_dynamic_std() {
let temp = tempdir().unwrap();
let release = temp.path().join("release");
let rust_libs = temp.path().join("rust-libs");
let dist = temp.path().join("dist");
fs::create_dir(&release).unwrap();
fs::create_dir(&rust_libs).unwrap();
for file in [
"bds-ui.exe",
"bds-cli.exe",
"bds-mcp.exe",
"bds_core.dll",
"bds_server.dll",
] {
fs::write(release.join(file), format!("MZ-{file}")).unwrap();
}
let assets = package("v1.2.3", &release, &rust_libs, "windows-x64", &dist).unwrap();
assert_eq!(assets.len(), 2);
let desktop = dist.join("bds-desktop-v1.2.3-windows-x64.zip");
let mut archive = zip::ZipArchive::new(File::open(desktop).unwrap()).unwrap();
let mut contents = Vec::new();
archive
.by_name("bds-desktop-v1.2.3-windows-x64/bds-ui.exe")
.unwrap()
.read_to_end(&mut contents)
.unwrap();
assert_eq!(contents, b"MZ-bds-ui.exe");
assert!(
archive
.by_name("bds-desktop-v1.2.3-windows-x64/bds_core.dll")
.is_ok()
);
}
#[test]
fn linux_package_contains_the_desktop_application() {
let temp = tempdir().unwrap();
let release = temp.path().join("release");
let rust_libs = temp.path().join("rust-libs");
let dist = temp.path().join("dist");
fs::create_dir(&release).unwrap();
fs::create_dir(&rust_libs).unwrap();
for file in [
"bds-ui",
"bds-cli",
"bds-mcp",
"libbds_core.so",
"libbds_server.so",
] {
fs::write(release.join(file), file).unwrap();
}
let assets = package("v1.2.3", &release, &rust_libs, "linux-arm64", &dist).unwrap();
assert_eq!(
assets,
[
dist.join("bds-cli-v1.2.3-linux-arm64.tar.gz"),
dist.join("bds-desktop-v1.2.3-linux-arm64.tar.gz"),
]
);
let desktop = File::open(dist.join("bds-desktop-v1.2.3-linux-arm64.tar.gz")).unwrap();
let decoder = flate2::read::GzDecoder::new(desktop);
let mut archive = tar::Archive::new(decoder);
let names = archive
.entries()
.unwrap()
.map(|entry| entry.unwrap().path().unwrap().into_owned())
.collect::<Vec<_>>();
assert!(names.iter().any(|name| name.ends_with("bds-ui")));
assert!(names.iter().any(|name| name.ends_with("libbds_core.so")));
}
#[test]
fn macho_runtime_install_names_are_rewritten_to_rpath() {
let temp = tempdir().unwrap();
let macho = temp.path().join("libbds_core.dylib");
let old_name = b"/build/target/release/libbds_core.dylib\0";
let command_size = (24 + old_name.len()).div_ceil(8) * 8;
let mut bytes = vec![0_u8; 32 + command_size];
bytes[0..4].copy_from_slice(&0xfeedfacf_u32.to_le_bytes());
bytes[16..20].copy_from_slice(&1_u32.to_le_bytes());
bytes[20..24].copy_from_slice(&(command_size as u32).to_le_bytes());
bytes[32..36].copy_from_slice(&LC_ID_DYLIB.to_le_bytes());
bytes[36..40].copy_from_slice(&(command_size as u32).to_le_bytes());
bytes[40..44].copy_from_slice(&24_u32.to_le_bytes());
bytes[56..56 + old_name.len()].copy_from_slice(old_name);
fs::write(&macho, bytes).unwrap();
rewrite_macho_rpaths(&macho, &["libbds_core.dylib".to_owned()]).unwrap();
let rewritten = fs::read(macho).unwrap();
let name_end = rewritten[56..].iter().position(|byte| *byte == 0).unwrap();
assert_eq!(&rewritten[56..56 + name_end], b"@rpath/libbds_core.dylib");
}
}